Merge pull request #58 from valueonag/feat/demo-system-readieness
Feat/demo system readieness
This commit is contained in:
commit
2994f3a090
97 changed files with 8573 additions and 2304 deletions
1626
package-lock.json
generated
1626
package-lock.json
generated
File diff suppressed because it is too large
Load diff
14
package.json
14
package.json
|
|
@ -11,7 +11,11 @@
|
|||
"build:prod": "tsc -b && vite build --mode prod",
|
||||
"build:int": "tsc -b && vite build --mode int",
|
||||
"lint": "eslint .",
|
||||
"preview": "vite preview"
|
||||
"preview": "vite preview",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"test:ui": "vitest --ui",
|
||||
"test:coverage": "vitest run --coverage"
|
||||
},
|
||||
"dependencies": {
|
||||
"@azure/msal-browser": "^4.12.0",
|
||||
|
|
@ -47,18 +51,24 @@
|
|||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.30.1",
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
"@testing-library/react": "^16.3.2",
|
||||
"@testing-library/user-event": "^14.6.1",
|
||||
"@types/node": "^24.7.2",
|
||||
"@types/proj4": "^2.5.6",
|
||||
"@types/react": "^19.1.8",
|
||||
"@types/react-dom": "^19.1.6",
|
||||
"@vitejs/plugin-react": "^4.6.0",
|
||||
"@vitest/coverage-v8": "^2.1.9",
|
||||
"eslint": "^9.30.1",
|
||||
"eslint-plugin-react-hooks": "^5.2.0",
|
||||
"eslint-plugin-react-refresh": "^0.4.20",
|
||||
"globals": "^16.3.0",
|
||||
"jsdom": "^25.0.1",
|
||||
"typescript": "~5.8.3",
|
||||
"typescript-eslint": "^8.35.1",
|
||||
"vite": "^5.4.10",
|
||||
"vite-plugin-html": "^3.2.2"
|
||||
"vite-plugin-html": "^3.2.2",
|
||||
"vitest": "^2.1.9"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -46,7 +46,14 @@ import { getApiBaseUrl } from '../config/config';
|
|||
|
||||
const api = axios.create({
|
||||
baseURL: getApiBaseUrl(),
|
||||
withCredentials: true
|
||||
withCredentials: true,
|
||||
// FastAPI expects repeat-style array query params (``?ids=1&ids=2``).
|
||||
// Axios v1.x default would render ``?ids[]=1&ids[]=2``, which FastAPI
|
||||
// silently drops -- e.g. ``trackerIds`` filters on the Redmine stats
|
||||
// endpoint never reach the route. Setting ``indexes: null`` switches
|
||||
// the URLSearchParams visitor to repeat format. Applies globally so
|
||||
// every endpoint with array query params gets it for free.
|
||||
paramsSerializer: { indexes: null },
|
||||
});
|
||||
|
||||
// Add a request interceptor to add the auth token, context headers, and log backend IP
|
||||
|
|
|
|||
|
|
@ -1,4 +1,7 @@
|
|||
import { ApiRequestOptions } from '../hooks/useApi';
|
||||
import type { AttributeType } from '../utils/attributeTypeMapper';
|
||||
|
||||
export type { AttributeType };
|
||||
|
||||
// ============================================================================
|
||||
// TYPES & INTERFACES
|
||||
|
|
@ -7,7 +10,7 @@ import { ApiRequestOptions } from '../hooks/useApi';
|
|||
export interface AttributeDefinition {
|
||||
name: string;
|
||||
label: string;
|
||||
type: 'string' | 'number' | 'date' | 'boolean' | 'enum' | 'text' | 'email' | 'checkbox' | 'select' | 'multiselect' | 'textarea';
|
||||
type: AttributeType;
|
||||
sortable?: boolean;
|
||||
filterable?: boolean;
|
||||
searchable?: boolean;
|
||||
|
|
|
|||
|
|
@ -29,7 +29,7 @@ export interface BillingTransaction {
|
|||
aicoreProvider?: string;
|
||||
aicoreModel?: string;
|
||||
createdByUserId?: string;
|
||||
createdAt?: string;
|
||||
sysCreatedAt?: string;
|
||||
mandateId?: string;
|
||||
mandateName?: string;
|
||||
userId?: string;
|
||||
|
|
|
|||
|
|
@ -169,11 +169,63 @@ export interface MfaChallengeEvent {
|
|||
|
||||
// SSE Event Types
|
||||
export interface TeamsbotSSEEvent {
|
||||
type: 'transcript' | 'botResponse' | 'analysis' | 'suggestedResponse' | 'statusChange' | 'error' | 'ping' | 'sessionState' | 'ttsDeliveryStatus' | 'mfaChallenge' | 'mfaResolved' | 'chatSendFailed';
|
||||
type:
|
||||
| 'transcript'
|
||||
| 'botResponse'
|
||||
| 'analysis'
|
||||
| 'suggestedResponse'
|
||||
| 'statusChange'
|
||||
| 'error'
|
||||
| 'ping'
|
||||
| 'sessionState'
|
||||
| 'ttsDeliveryStatus'
|
||||
| 'mfaChallenge'
|
||||
| 'mfaResolved'
|
||||
| 'chatSendFailed'
|
||||
| 'directorPrompt'
|
||||
| 'agentRun'
|
||||
| 'botConnectionState';
|
||||
data: any;
|
||||
timestamp?: string;
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Director Prompts (private operator instructions during a live meeting)
|
||||
// =========================================================================
|
||||
|
||||
export type DirectorPromptMode = 'oneShot' | 'persistent';
|
||||
export type DirectorPromptStatus =
|
||||
| 'queued'
|
||||
| 'running'
|
||||
| 'succeeded'
|
||||
| 'failed'
|
||||
| 'consumed';
|
||||
|
||||
export const DIRECTOR_PROMPT_TEXT_LIMIT = 8000;
|
||||
export const DIRECTOR_PROMPT_FILE_LIMIT = 10;
|
||||
|
||||
export interface DirectorPrompt {
|
||||
id: string;
|
||||
sessionId: string;
|
||||
instanceId: string;
|
||||
operatorUserId: string;
|
||||
text: string;
|
||||
mode: DirectorPromptMode;
|
||||
fileIds: string[];
|
||||
status: DirectorPromptStatus;
|
||||
statusMessage?: string;
|
||||
createdAt: string;
|
||||
consumedAt?: string;
|
||||
agentRunId?: string;
|
||||
responseText?: string;
|
||||
}
|
||||
|
||||
export interface DirectorPromptCreateRequest {
|
||||
text: string;
|
||||
mode: DirectorPromptMode;
|
||||
fileIds?: string[];
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// API FUNCTIONS
|
||||
// ============================================================================
|
||||
|
|
@ -289,6 +341,29 @@ export async function listSystemBots(instanceId: string): Promise<{ bots: System
|
|||
return response.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new system bot account. The password is encrypted server-side
|
||||
* before storage; the API never returns the password back. SysAdmin only.
|
||||
*/
|
||||
export async function createSystemBot(
|
||||
instanceId: string,
|
||||
payload: { email: string; password: string; name?: string },
|
||||
): Promise<{ bot: SystemBot }> {
|
||||
const response = await api.post(`/api/teamsbot/${instanceId}/system-bots`, payload);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a system bot account. SysAdmin only.
|
||||
*/
|
||||
export async function deleteSystemBot(
|
||||
instanceId: string,
|
||||
botId: string,
|
||||
): Promise<{ deleted: boolean }> {
|
||||
const response = await api.delete(`/api/teamsbot/${instanceId}/system-bots/${botId}`);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Test TTS voice with AI-generated sample text. Returns base64-encoded audio.
|
||||
*/
|
||||
|
|
@ -452,3 +527,50 @@ export async function submitMfaCode(
|
|||
});
|
||||
return response.data;
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Director Prompts
|
||||
// =========================================================================
|
||||
|
||||
/**
|
||||
* Submit a private director prompt to the running bot. Triggers the full
|
||||
* agent path (web, mail, RAG, etc.) and delivers the answer into the meeting.
|
||||
*/
|
||||
export async function submitDirectorPrompt(
|
||||
instanceId: string,
|
||||
sessionId: string,
|
||||
body: DirectorPromptCreateRequest,
|
||||
): Promise<{ prompt: DirectorPrompt }> {
|
||||
const response = await api.post(
|
||||
`/api/teamsbot/${instanceId}/sessions/${sessionId}/directorPrompts`,
|
||||
body,
|
||||
);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* List director prompts for a session (operator's own prompts only).
|
||||
*/
|
||||
export async function listDirectorPrompts(
|
||||
instanceId: string,
|
||||
sessionId: string,
|
||||
): Promise<{ prompts: DirectorPrompt[] }> {
|
||||
const response = await api.get(
|
||||
`/api/teamsbot/${instanceId}/sessions/${sessionId}/directorPrompts`,
|
||||
);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a (typically persistent) director prompt.
|
||||
*/
|
||||
export async function deleteDirectorPrompt(
|
||||
instanceId: string,
|
||||
sessionId: string,
|
||||
promptId: string,
|
||||
): Promise<{ deleted: boolean; promptId: string }> {
|
||||
const response = await api.delete(
|
||||
`/api/teamsbot/${instanceId}/sessions/${sessionId}/directorPrompts/${promptId}`,
|
||||
);
|
||||
return response.data;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -853,16 +853,46 @@ export async function fetchChartOfAccounts(
|
|||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Submits a background job that pushes positions to the accounting system and
|
||||
* polls `/api/jobs/{jobId}` until the job reaches a terminal status. Returns
|
||||
* the same `{ total, success, skipped, errors, results }` payload that the
|
||||
* legacy synchronous endpoint used to return -- but does NOT block the user
|
||||
* while the (potentially long) external accounting calls run in the worker.
|
||||
*/
|
||||
export async function syncPositionsToAccounting(
|
||||
request: ApiRequestFunction,
|
||||
instanceId: string,
|
||||
positionIds: string[]
|
||||
): Promise<{ total: number; success: number; errors: number; results: any[] }> {
|
||||
return await request({
|
||||
positionIds: string[],
|
||||
opts?: { pollMs?: number; onProgress?: (progress: number, message?: string | null) => void }
|
||||
): Promise<{ total: number; success: number; skipped?: number; errors: number; results: any[] }> {
|
||||
const submission = await request({
|
||||
url: `${_getTrusteeBaseUrl(instanceId)}/accounting/sync`,
|
||||
method: 'post',
|
||||
data: { positionIds }
|
||||
});
|
||||
|
||||
const jobId: string | undefined = submission?.jobId;
|
||||
if (!jobId) {
|
||||
throw new Error('Background job could not be started (missing jobId).');
|
||||
}
|
||||
|
||||
const pollMs = opts?.pollMs ?? 1500;
|
||||
const TERMINAL = new Set(['SUCCESS', 'ERROR', 'CANCELLED']);
|
||||
|
||||
while (true) {
|
||||
const job = await request({ url: `/api/jobs/${jobId}`, method: 'get' });
|
||||
if (opts?.onProgress) {
|
||||
opts.onProgress(Number(job?.progress ?? 0), job?.progressMessage ?? null);
|
||||
}
|
||||
if (job?.status && TERMINAL.has(job.status)) {
|
||||
if (job.status === 'SUCCESS' && job.result) {
|
||||
return job.result;
|
||||
}
|
||||
throw new Error(job?.errorMessage || 'Sync-Job fehlgeschlagen');
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, pollMs));
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchSyncStatus(
|
||||
|
|
|
|||
|
|
@ -29,6 +29,7 @@ export interface PortField {
|
|||
/** Plain string or per-language map from the API catalog. */
|
||||
description: string | Record<string, string>;
|
||||
required: boolean;
|
||||
enumValues?: string[] | null;
|
||||
}
|
||||
|
||||
export interface PortSchema {
|
||||
|
|
@ -40,8 +41,14 @@ export interface InputPortDef {
|
|||
accepts: string[];
|
||||
}
|
||||
|
||||
/** Graph-defined output schema (e.g. form fields from node parameters). */
|
||||
export interface GraphDefinedSchemaRef {
|
||||
kind: 'fromGraph';
|
||||
parameter: string;
|
||||
}
|
||||
|
||||
export interface OutputPortDef {
|
||||
schema: string;
|
||||
schema: string | GraphDefinedSchemaRef;
|
||||
dynamic?: boolean;
|
||||
deriveFrom?: string;
|
||||
}
|
||||
|
|
@ -90,7 +97,7 @@ export interface Automation2GraphNode {
|
|||
type: string;
|
||||
parameters?: Record<string, unknown>;
|
||||
inputPorts?: Array<{ name: string; schema: string; accepts?: string[] }>;
|
||||
outputPorts?: Array<{ name: string; schema: string }>;
|
||||
outputPorts?: Array<{ name: string; schema: string | GraphDefinedSchemaRef }>;
|
||||
}
|
||||
|
||||
export interface Automation2Connection {
|
||||
|
|
@ -109,6 +116,10 @@ export interface ExecuteGraphResponse {
|
|||
success: boolean;
|
||||
nodeOutputs?: Record<string, unknown>;
|
||||
error?: string;
|
||||
/** Soft, non-blocking message displayed alongside a successful response.
|
||||
* Used e.g. by the Save flow to surface "Gespeichert mit X Pflicht-Fehlern"
|
||||
* without flipping `success` to `false`. */
|
||||
warning?: string;
|
||||
stopped?: boolean;
|
||||
failedNode?: string;
|
||||
paused?: boolean;
|
||||
|
|
@ -145,8 +156,8 @@ export interface Automation2Workflow {
|
|||
stuckAtNodeId?: string;
|
||||
/** Enriched: human-readable label for stuck node */
|
||||
stuckAtNodeLabel?: string;
|
||||
/** Enriched: created timestamp (seconds) */
|
||||
createdAt?: number;
|
||||
/** From PowerOnModel base — record creation timestamp (seconds) */
|
||||
sysCreatedAt?: number;
|
||||
/** Enriched: last run started timestamp (seconds) */
|
||||
lastStartedAt?: number;
|
||||
}
|
||||
|
|
@ -264,8 +275,55 @@ export async function fetchNodeTypes(
|
|||
});
|
||||
const nodeTypes = data?.nodeTypes ?? [];
|
||||
const categories = data?.categories ?? [];
|
||||
console.log(`${LOG} fetchNodeTypes response: ${nodeTypes.length} nodeTypes, ${categories.length} categories`);
|
||||
return { nodeTypes, categories };
|
||||
const portTypeCatalog = data?.portTypeCatalog ?? undefined;
|
||||
const systemVariables = data?.systemVariables ?? undefined;
|
||||
console.log(
|
||||
`${LOG} fetchNodeTypes response: ${nodeTypes.length} nodeTypes, ${categories.length} categories, ` +
|
||||
`${portTypeCatalog ? Object.keys(portTypeCatalog).length : 0} portTypes, ` +
|
||||
`${systemVariables ? Object.keys(systemVariables).length : 0} sysVars`
|
||||
);
|
||||
return { nodeTypes, categories, portTypeCatalog, systemVariables };
|
||||
}
|
||||
|
||||
export interface UpstreamPathEntry {
|
||||
producerNodeId: string;
|
||||
producerLabel?: string;
|
||||
path: (string | number)[];
|
||||
type: string;
|
||||
label: string;
|
||||
scopeOrigin: 'data' | 'loop' | 'system';
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/workflows/{instanceId}/upstream-paths — pickable upstream paths for DataPicker / AI.
|
||||
*/
|
||||
export async function postUpstreamPaths(
|
||||
request: ApiRequestFunction,
|
||||
instanceId: string,
|
||||
graph: Automation2Graph,
|
||||
nodeId: string
|
||||
): Promise<{ paths: UpstreamPathEntry[] }> {
|
||||
const data = await request({
|
||||
url: `/api/workflows/${instanceId}/upstream-paths`,
|
||||
method: 'post',
|
||||
data: { graph, nodeId },
|
||||
});
|
||||
return { paths: (data?.paths ?? []) as UpstreamPathEntry[] };
|
||||
}
|
||||
|
||||
/** GET saved workflow graph variant of upstream-paths (requires workflowId). */
|
||||
export async function getUpstreamPathsSaved(
|
||||
request: ApiRequestFunction,
|
||||
instanceId: string,
|
||||
workflowId: string,
|
||||
nodeId: string
|
||||
): Promise<{ paths: UpstreamPathEntry[] }> {
|
||||
const data = await request({
|
||||
url: `/api/workflows/${instanceId}/upstream-paths/${encodeURIComponent(nodeId)}`,
|
||||
method: 'get',
|
||||
params: { workflowId },
|
||||
});
|
||||
return { paths: (data?.paths ?? []) as UpstreamPathEntry[] };
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@
|
|||
import React, { createContext, useContext, useMemo } from 'react';
|
||||
import type { CanvasNode, CanvasConnection } from '../editor/FlowCanvas';
|
||||
import { getAvailableSources } from '../nodes/shared/dataFlowGraph';
|
||||
import type { NodeType, PortSchema, SystemVariable } from '../../../api/workflowApi';
|
||||
import type { ApiRequestFunction, NodeType, PortField, PortSchema, SystemVariable } from '../../../api/workflowApi';
|
||||
|
||||
export interface Automation2DataFlowContextValue {
|
||||
currentNodeId: string;
|
||||
|
|
@ -19,6 +19,11 @@ export interface Automation2DataFlowContextValue {
|
|||
systemVariables: Record<string, SystemVariable>;
|
||||
getNodeLabel: (node: { id: string; title?: string; label?: string; type?: string }) => string;
|
||||
getAvailableSourceIds: () => string[];
|
||||
/** Present when rendered inside the flow editor (ConnectionPicker / tools). */
|
||||
instanceId?: string;
|
||||
request?: ApiRequestFunction;
|
||||
/** Build FormPayload-like schema from ``parameters[parameterKey]`` (fieldBuilder JSON). */
|
||||
parseGraphDefinedSchema: (parameterKey: string) => PortSchema | null;
|
||||
}
|
||||
|
||||
const Automation2DataFlowContext = createContext<Automation2DataFlowContextValue | null>(null);
|
||||
|
|
@ -36,6 +41,8 @@ interface Automation2DataFlowProviderProps {
|
|||
language: string;
|
||||
portTypeCatalog?: Record<string, PortSchema>;
|
||||
systemVariables?: Record<string, SystemVariable>;
|
||||
instanceId?: string;
|
||||
request?: ApiRequestFunction;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
|
|
@ -48,10 +55,52 @@ export const Automation2DataFlowProvider: React.FC<Automation2DataFlowProviderPr
|
|||
language,
|
||||
portTypeCatalog = {},
|
||||
systemVariables = {},
|
||||
instanceId,
|
||||
request,
|
||||
children,
|
||||
}) => {
|
||||
const value = useMemo((): Automation2DataFlowContextValue | null => {
|
||||
if (!node) return null;
|
||||
const parseGraphDefinedSchema = (parameterKey: string): PortSchema | null => {
|
||||
const raw = node.parameters?.[parameterKey];
|
||||
if (!Array.isArray(raw)) return null;
|
||||
const fields: PortField[] = [];
|
||||
for (const item of raw) {
|
||||
if (typeof item !== 'object' || item === null) continue;
|
||||
const rec = item as Record<string, unknown>;
|
||||
if (typeof rec.name !== 'string') continue;
|
||||
const lab = rec.label;
|
||||
const desc =
|
||||
typeof lab === 'string' ? lab : typeof lab === 'object' && lab !== null ? String((lab as Record<string, string>).de ?? '') : '';
|
||||
const ftype = typeof rec.type === 'string' ? rec.type : 'str';
|
||||
if (ftype === 'group' && Array.isArray(rec.fields)) {
|
||||
for (const sub of rec.fields as Record<string, unknown>[]) {
|
||||
if (!sub || typeof sub.name !== 'string') continue;
|
||||
const sl = sub.label;
|
||||
const sdesc =
|
||||
typeof sl === 'string'
|
||||
? sl
|
||||
: typeof sl === 'object' && sl !== null
|
||||
? String((sl as Record<string, string>).de ?? '')
|
||||
: '';
|
||||
fields.push({
|
||||
name: `${rec.name}.${sub.name}`,
|
||||
type: typeof sub.type === 'string' ? sub.type : 'str',
|
||||
description: (sdesc && sdesc.trim()) || `${rec.name}.${sub.name}`,
|
||||
required: Boolean(sub.required),
|
||||
});
|
||||
}
|
||||
continue;
|
||||
}
|
||||
fields.push({
|
||||
name: rec.name,
|
||||
type: ftype,
|
||||
description: (desc && desc.trim()) || rec.name,
|
||||
required: Boolean(rec.required),
|
||||
});
|
||||
}
|
||||
return fields.length ? { name: 'FormPayload_dynamic', fields } : null;
|
||||
};
|
||||
return {
|
||||
currentNodeId: node.id,
|
||||
nodes,
|
||||
|
|
@ -64,8 +113,11 @@ export const Automation2DataFlowProvider: React.FC<Automation2DataFlowProviderPr
|
|||
getNodeLabel: (n: { id: string; title?: string; label?: string; type?: string }) =>
|
||||
n.title ?? n.label ?? n.type ?? n.id,
|
||||
getAvailableSourceIds: () => getAvailableSources(node.id, nodes, connections),
|
||||
instanceId,
|
||||
request,
|
||||
parseGraphDefinedSchema,
|
||||
};
|
||||
}, [node?.id, nodes, connections, nodeOutputsPreview, nodeTypes, language, portTypeCatalog, systemVariables]);
|
||||
}, [node, nodes, connections, nodeOutputsPreview, nodeTypes, language, portTypeCatalog, systemVariables, instanceId, request]);
|
||||
|
||||
return (
|
||||
<Automation2DataFlowContext.Provider value={value}>
|
||||
|
|
|
|||
|
|
@ -256,6 +256,225 @@
|
|||
background: var(--bg-primary, #fff);
|
||||
}
|
||||
|
||||
/* Toolbar: context (load + name) is fluid with ellipsis; actions stay right-aligned. */
|
||||
.canvasHeaderRow {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
gap: 0.75rem;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.canvasHeaderRow {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.canvasHeaderContext {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
/* Closed <select> width must not follow the longest option label. */
|
||||
.canvasHeaderWorkflowSelect {
|
||||
flex: 0 0 auto;
|
||||
width: 12.5rem;
|
||||
max-width: 100%;
|
||||
padding: 0.4rem 0.5rem;
|
||||
min-height: 2rem;
|
||||
font-size: 0.85rem;
|
||||
border: 1px solid var(--border-color, #ccc);
|
||||
border-radius: 6px;
|
||||
background: var(--bg-primary, #fff);
|
||||
color: var(--text-primary, #333);
|
||||
}
|
||||
|
||||
.canvasHeaderTitleBlock {
|
||||
flex: 1 1 8rem;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.canvasHeaderTitle,
|
||||
.canvasHeaderTitle input {
|
||||
margin: 0;
|
||||
min-width: 0;
|
||||
font-size: 0.95rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary, #1a1a1a);
|
||||
}
|
||||
|
||||
.canvasHeaderTitle {
|
||||
line-height: 1.2;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.canvasHeaderTitleMuted {
|
||||
font-style: italic;
|
||||
font-weight: 500;
|
||||
opacity: 0.65;
|
||||
color: var(--text-secondary, #666);
|
||||
}
|
||||
|
||||
.canvasHeaderTitle input {
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
padding: 0.25rem 0.4rem;
|
||||
border: 1px solid var(--primary-color, #007bff);
|
||||
border-radius: 4px;
|
||||
outline: none;
|
||||
background: var(--bg-primary, #fff);
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.canvasHeaderActionPanel {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 0.4rem;
|
||||
padding: 0.35rem 0.5rem;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--border-color, #e0e0e0);
|
||||
background: var(--bg-secondary, #f8f9fa);
|
||||
flex: 0 1 auto;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
/* .retryButton sets margin-top for legacy error stacks — not wanted in the toolbar. */
|
||||
.canvasHeaderActionPanel button {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
/* Run label switches between "Ausführen", "Ausführen…", "Pflicht-Felder fehlen" — reserve space. */
|
||||
.canvasHeaderRunButton {
|
||||
min-width: 12.5rem;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.canvasHeaderActionPanel {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
}
|
||||
|
||||
.canvasHeaderVersionRow {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin-top: 0.5rem;
|
||||
padding-top: 0.5rem;
|
||||
border-top: 1px solid var(--border-color, #e8e8e8);
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.canvasHeaderVersionRow button {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.canvasHeaderVersionLabel {
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary, #666);
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.canvasHeaderVersionSelect {
|
||||
width: 11rem;
|
||||
max-width: 100%;
|
||||
padding: 0.3rem 0.45rem;
|
||||
font-size: 0.85rem;
|
||||
min-height: 1.9rem;
|
||||
border: 1px solid var(--border-color, #ccc);
|
||||
border-radius: 4px;
|
||||
background: var(--bg-primary, #fff);
|
||||
color: var(--text-primary, #333);
|
||||
}
|
||||
|
||||
.canvasHeaderSysadmin {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-secondary, #666);
|
||||
padding: 0.2rem 0.45rem;
|
||||
border: 1px dashed var(--border-color, #ccc);
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
white-space: nowrap;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.canvasHeaderNewSplit {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.canvasHeaderSplitPair {
|
||||
display: flex;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.canvasHeaderNewSplitMain {
|
||||
border-top-right-radius: 0;
|
||||
border-bottom-right-radius: 0;
|
||||
}
|
||||
|
||||
.canvasHeaderNewSplitMenu {
|
||||
border-top-left-radius: 0;
|
||||
border-bottom-left-radius: 0;
|
||||
padding-left: 0.25rem;
|
||||
padding-right: 0.4rem;
|
||||
border-left: 1px solid rgba(0, 0, 0, 0.12);
|
||||
}
|
||||
|
||||
.canvasHeaderMenuDropdown {
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
left: 0;
|
||||
z-index: 100;
|
||||
background: var(--bg-primary, #fff);
|
||||
border: 1px solid var(--border-color, #e0e0e0);
|
||||
border-radius: 6px;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.12);
|
||||
min-width: 11rem;
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
.canvasHeaderMenuItem {
|
||||
display: block;
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
padding: 0.5rem 0.75rem;
|
||||
border: none;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-primary, #333);
|
||||
}
|
||||
|
||||
.canvasHeaderMenuItem:hover {
|
||||
background: var(--bg-hover, #e9ecef);
|
||||
}
|
||||
|
||||
.canvasHeaderMenuItem + .canvasHeaderMenuItem {
|
||||
border-top: 1px solid var(--border-color, #e0e0e0);
|
||||
}
|
||||
|
||||
.canvasTitle {
|
||||
margin: 0;
|
||||
font-size: 0.875rem;
|
||||
|
|
@ -507,20 +726,32 @@
|
|||
cursor: copy;
|
||||
}
|
||||
|
||||
/* Node Config Panel */
|
||||
/* Node Config Panel
|
||||
* Fixed-width side panel. The `box-sizing: border-box` + `overflow-x: hidden`
|
||||
* pair acts as a safety net so long unbreakable strings (type names like
|
||||
* `List[ActionDocument]`, hashed IDs, refs like `← node.path → field`) can
|
||||
* never push content out of the panel frame. Children rely on this; e.g.
|
||||
* `RequiredAttributePicker` lays out label/badge so the badge wraps below
|
||||
* a long label rather than escaping to the right.
|
||||
*/
|
||||
.nodeConfigPanel {
|
||||
padding: 1rem;
|
||||
background: var(--bg-primary, #fff);
|
||||
border-left: 1px solid var(--border-color, #e0e0e0);
|
||||
width: 280px;
|
||||
flex-shrink: 0;
|
||||
box-sizing: border-box;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
min-width: 0;
|
||||
overflow-wrap: anywhere;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.nodeConfigPanel h4 {
|
||||
margin: 0 0 0.75rem 0;
|
||||
font-size: 0.9rem;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.nodeConfigNameRow {
|
||||
|
|
@ -547,6 +778,8 @@
|
|||
font-size: 0.75rem;
|
||||
color: var(--text-secondary, #666);
|
||||
line-height: 1.4;
|
||||
overflow-wrap: anywhere;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.nodeConfigPanel label {
|
||||
|
|
@ -572,7 +805,8 @@
|
|||
min-height: 60px;
|
||||
}
|
||||
|
||||
/* Kein Primär-Button-Stil für Zeitplan-Karten / Wochentage / Monat-Jahr-Chips */
|
||||
/* Kein Primär-Button-Stil für Zeitplan-Karten / Wochentage / Monat-Jahr-Chips
|
||||
(DataPicker-Dialog wird per createPortal an document.body gehangen — nicht hier). */
|
||||
.nodeConfigPanel
|
||||
button:not(.scheduleModeCard):not(.scheduleDayOn):not(.scheduleDayOff):not(.scheduleSubModeBtn) {
|
||||
margin-top: 0.5rem;
|
||||
|
|
@ -1284,53 +1518,112 @@
|
|||
min-width: 0;
|
||||
}
|
||||
|
||||
/* Data Picker */
|
||||
/* Data Picker — rendered with createPortal(document.body) so it is not affected
|
||||
by .nodeConfigPanel’s generic CTA `button` styles. */
|
||||
|
||||
.dataPickerOverlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.35);
|
||||
background: rgba(0, 0, 0, 0.4);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1000;
|
||||
z-index: 11000;
|
||||
padding: 1rem;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.dataPickerModal {
|
||||
background: var(--bg-primary, #fff);
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.15);
|
||||
max-width: 420px;
|
||||
max-height: 80vh;
|
||||
color: var(--text-primary, #1a1a1a);
|
||||
border-radius: 10px;
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.12);
|
||||
border: 1px solid var(--border-color, #e0e0e0);
|
||||
max-width: min(420px, 100vw - 2rem);
|
||||
width: 100%;
|
||||
max-height: min(80vh, 640px);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.dataPickerHeader {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
padding: 1rem 1.25rem;
|
||||
gap: 0.75rem;
|
||||
padding: 1rem 1.15rem;
|
||||
border-bottom: 1px solid var(--border-color, #e0e0e0);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.dataPickerHeaderControls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.dataPickerTitle {
|
||||
margin: 0;
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary, #1a1a1a);
|
||||
line-height: 1.35;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 0.5rem 0.4rem;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.dataPickerTypeBadge {
|
||||
display: inline-block;
|
||||
font-size: 0.7rem;
|
||||
font-weight: 400;
|
||||
font-family: ui-monospace, 'Cascadia Code', monospace;
|
||||
color: var(--text-secondary, #666);
|
||||
background: var(--bg-secondary, #f0f0f0);
|
||||
border: 1px solid var(--border-color, #ddd);
|
||||
border-radius: 4px;
|
||||
padding: 0.1rem 0.45rem;
|
||||
line-height: 1.2;
|
||||
max-width: 100%;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.dataPickerStrictLabel {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.3rem;
|
||||
font-size: 0.7rem;
|
||||
color: var(--text-secondary, #666);
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.dataPickerClose {
|
||||
background: none;
|
||||
border: none;
|
||||
font-size: 1.5rem;
|
||||
cursor: pointer;
|
||||
color: var(--text-secondary, #666);
|
||||
padding: 0 0.25rem;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
flex-shrink: 0;
|
||||
background: var(--bg-secondary, #f5f5f5);
|
||||
border: 1px solid var(--border-color, #d0d0d0);
|
||||
border-radius: 6px;
|
||||
font-size: 1.25rem;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
color: var(--text-primary, #333);
|
||||
}
|
||||
|
||||
.dataPickerClose:hover {
|
||||
color: var(--text-primary, #333);
|
||||
background: var(--bg-hover, #e9ecef);
|
||||
color: var(--text-primary, #1a1a1a);
|
||||
border-color: var(--border-color, #b8b8b8);
|
||||
}
|
||||
|
||||
.dataPickerBody {
|
||||
|
|
@ -1345,24 +1638,35 @@
|
|||
}
|
||||
|
||||
.dataPickerNodeSection {
|
||||
margin-bottom: 0.75rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
/* Expandable source row: neutral “list row”, not a primary CTA. */
|
||||
.dataPickerNodeHeader {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
padding: 0.5rem 0;
|
||||
background: none;
|
||||
border: none;
|
||||
box-sizing: border-box;
|
||||
padding: 0.5rem 0.6rem;
|
||||
background: var(--bg-secondary, #f4f5f7);
|
||||
border: 1px solid var(--border-color, #dde1e5);
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
font-size: 0.875rem;
|
||||
font-size: 0.85rem;
|
||||
text-align: left;
|
||||
color: var(--text-primary, #1a1a1a);
|
||||
margin: 0;
|
||||
transition: background 0.12s, border-color 0.12s, box-shadow 0.12s;
|
||||
}
|
||||
|
||||
.dataPickerNodeHeader:hover {
|
||||
background: var(--bg-hover, #f5f5f5);
|
||||
border-radius: 4px;
|
||||
background: var(--bg-hover, #e9ebef);
|
||||
border-color: var(--border-color, #c8cfd6);
|
||||
}
|
||||
|
||||
.dataPickerNodeHeader:focus-visible {
|
||||
outline: 2px solid var(--primary-color, #4a6fa5);
|
||||
outline-offset: 1px;
|
||||
}
|
||||
|
||||
.dataPickerExpandIcon {
|
||||
|
|
@ -1401,6 +1705,43 @@
|
|||
border-color: var(--primary-color, #007bff);
|
||||
}
|
||||
|
||||
/* Hover safety net: every nested span in a leaf inherits the white text so
|
||||
* type-hints and meta info stay readable on the blue hover background. */
|
||||
.dataPickerLeaf:hover * {
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
/* Inline type-hint after a leaf label, e.g. "documents (List[ActionDocument])". */
|
||||
.dataPickerLeafType {
|
||||
color: var(--text-secondary, #666);
|
||||
font-size: 10px;
|
||||
margin-left: 4px;
|
||||
}
|
||||
|
||||
/* Schema-name hint on the node-section header row. */
|
||||
.dataPickerNodeSchemaHint {
|
||||
color: var(--text-secondary, #666);
|
||||
font-size: 10px;
|
||||
margin-left: 4px;
|
||||
}
|
||||
|
||||
/* "iterieren" affordance — visually distinct (subtle accent), readable on
|
||||
* the picker's white background and on the leaf's blue hover background. */
|
||||
.dataPickerIterateBtn {
|
||||
font-size: 10px;
|
||||
padding: 2px 6px;
|
||||
background: var(--bg-secondary, #f5f7fa);
|
||||
color: var(--primary-color, #007bff);
|
||||
border: 1px solid var(--border-color, #e0e0e0);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.dataPickerIterateBtn:hover {
|
||||
background: var(--primary-color, #007bff);
|
||||
color: #fff;
|
||||
border-color: var(--primary-color, #007bff);
|
||||
}
|
||||
|
||||
/* Dynamic Value Field */
|
||||
.dynamicValueField {
|
||||
display: flex;
|
||||
|
|
|
|||
|
|
@ -45,6 +45,8 @@ import {
|
|||
buildInvocationsForPrimaryKind,
|
||||
} from '../nodes/runtime/workflowStartSync';
|
||||
import { buildNodeOutputsPreview, setPortTypeCatalog as setRegistryCatalog } from '../nodes/shared/outputPreviewRegistry';
|
||||
import { findGraphErrors } from '../nodes/shared/paramValidation';
|
||||
import { getLabel as getParamLabel } from '../nodes/shared/utils';
|
||||
import { Automation2DataFlowProvider } from '../context/Automation2DataFlowContext';
|
||||
import { usePrompt } from '../../../hooks/usePrompt';
|
||||
import { EditorChatPanel } from './EditorChatPanel';
|
||||
|
|
@ -56,6 +58,7 @@ import type { UdbContext, UdbTab } from '../../../components/UnifiedDataBar';
|
|||
import styles from './Automation2FlowEditor.module.css';
|
||||
|
||||
import { useLanguage } from '../../../providers/language/LanguageContext';
|
||||
import { useToast } from '../../../contexts/ToastContext';
|
||||
|
||||
const LOG = '[Automation2]';
|
||||
|
||||
|
|
@ -88,6 +91,7 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({ in
|
|||
onSourcesChanged,
|
||||
}) => {
|
||||
const { t } = useLanguage();
|
||||
const { showError } = useToast();
|
||||
const { request } = useApiRequest();
|
||||
const { prompt: promptInput, PromptDialog } = usePrompt();
|
||||
const [nodeTypes, setNodeTypes] = useState<NodeType[]>([]);
|
||||
|
|
@ -135,6 +139,15 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({ in
|
|||
const [sidebarWidth, setSidebarWidth] = useState(() => {
|
||||
try { const v = parseInt(localStorage.getItem('flowEditor.sidebarWidth') ?? ''); return v >= 200 && v <= 500 ? v : 280; } catch { return 280; }
|
||||
});
|
||||
// Verbose schema toggle: shows the static type-reference block (input/output
|
||||
// schema) and parameter type-badges in NodeConfigPanel. Only the
|
||||
// CanvasHeader exposes the toggle (sysadmin-only); persisted to localStorage.
|
||||
const [verboseSchema, setVerboseSchema] = useState(() => {
|
||||
try { return localStorage.getItem('flowEditor.verboseSchema') === '1'; } catch { return false; }
|
||||
});
|
||||
useEffect(() => {
|
||||
try { localStorage.setItem('flowEditor.verboseSchema', verboseSchema ? '1' : '0'); } catch { /* ignore */ }
|
||||
}, [verboseSchema]);
|
||||
const resizingRef = useRef<{ target: 'left' | 'right'; startX: number; startW: number } | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
|
|
@ -180,6 +193,21 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({ in
|
|||
[canvasNodes, nodeTypes, executeResult?.nodeOutputs]
|
||||
);
|
||||
|
||||
// Phase-4 Schicht-4 — Per-node required-but-unbound errors used by both the
|
||||
// canvas error badges and the Run-button gate. Graph-level: Save stays
|
||||
// unconditional (Schicht-4 invariant: WIP must always be persistable).
|
||||
const nodeErrors = useMemo(
|
||||
() =>
|
||||
findGraphErrors(
|
||||
canvasNodes,
|
||||
nodeTypes,
|
||||
(p) => getParamLabel(p.description, language) || p.name,
|
||||
),
|
||||
[canvasNodes, nodeTypes, language]
|
||||
);
|
||||
const hasGraphErrors = useMemo(() => Object.keys(nodeErrors).length > 0, [nodeErrors]);
|
||||
const firstErrorNodeId = useMemo(() => Object.keys(nodeErrors)[0] ?? null, [nodeErrors]);
|
||||
|
||||
const applyGraphWithSync = useCallback(
|
||||
(graph: Automation2Graph | null | undefined, wfInvocations: WorkflowEntryPoint[] | undefined) => {
|
||||
const inv = wfInvocations?.length ? wfInvocations : _buildDefaultInvocations(t('Jetzt ausführen'));
|
||||
|
|
@ -211,6 +239,19 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({ in
|
|||
setExecuteResult({ success: false, error: t('Keine Nodes im Workflow.') });
|
||||
return;
|
||||
}
|
||||
// Phase-4 Schicht-4: Run blockiert bei Pflicht-Fehlern. Save bleibt offen.
|
||||
if (Object.keys(nodeErrors).length > 0) {
|
||||
const firstId = Object.keys(nodeErrors)[0];
|
||||
const firstNode = canvasNodes.find((n) => n.id === firstId);
|
||||
if (firstNode) setSelectedNode(firstNode);
|
||||
setExecuteResult({
|
||||
success: false,
|
||||
error:
|
||||
t('Workflow hat Pflicht-Felder ohne Quelle. Bitte erst beheben.') +
|
||||
(firstNode ? ` (${firstNode.title ?? firstNode.label ?? firstNode.type})` : ''),
|
||||
});
|
||||
return;
|
||||
}
|
||||
setExecuting(true);
|
||||
setExecuteResult(null);
|
||||
try {
|
||||
|
|
@ -228,7 +269,7 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({ in
|
|||
} finally {
|
||||
setExecuting(false);
|
||||
}
|
||||
}, [request, instanceId, canvasNodes, canvasConnections, currentWorkflowId, invocations, t]);
|
||||
}, [request, instanceId, canvasNodes, canvasConnections, currentWorkflowId, invocations, t, nodeErrors]);
|
||||
|
||||
const handleSave = useCallback(async () => {
|
||||
const graph = toApiGraph(canvasNodes, canvasConnections);
|
||||
|
|
@ -236,11 +277,28 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({ in
|
|||
setExecuteResult({ success: false, error: t('Keine Nodes zum Speichern.') });
|
||||
return;
|
||||
}
|
||||
// Phase-4 Schicht-4 / AC 9: Save bleibt bei Pflicht-Fehlern erlaubt,
|
||||
// aber wir berichten die Anzahl in einem nicht-blockierenden Warning,
|
||||
// damit der User die WIP-Lücken nicht stillschweigend persistiert.
|
||||
const errorCount = Object.values(nodeErrors).reduce(
|
||||
(acc, list) => acc + list.length,
|
||||
0,
|
||||
);
|
||||
const errorNodeCount = Object.keys(nodeErrors).length;
|
||||
const _buildSaveResult = (): ExecuteGraphResponse => ({
|
||||
success: true,
|
||||
warning:
|
||||
errorCount > 0
|
||||
? t('Gespeichert mit {n} Pflicht-Fehlern in {m} Nodes.')
|
||||
.replace('{n}', String(errorCount))
|
||||
.replace('{m}', String(errorNodeCount))
|
||||
: undefined,
|
||||
});
|
||||
setSaving(true);
|
||||
try {
|
||||
if (currentWorkflowId) {
|
||||
await updateWorkflow(request, instanceId, currentWorkflowId, { graph, invocations });
|
||||
setExecuteResult({ success: true } as ExecuteGraphResponse);
|
||||
setExecuteResult(_buildSaveResult());
|
||||
} else {
|
||||
const label = await promptInput(t('Workflow-Name:'), {
|
||||
title: t('Workflow speichern'),
|
||||
|
|
@ -259,14 +317,14 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({ in
|
|||
setCurrentWorkflowId(created.id);
|
||||
if (created.invocations?.length) setInvocations(created.invocations);
|
||||
setWorkflows((prev) => [...prev, created]);
|
||||
setExecuteResult({ success: true } as ExecuteGraphResponse);
|
||||
setExecuteResult(_buildSaveResult());
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
setExecuteResult({ success: false, error: err instanceof Error ? err.message : String(err) });
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}, [request, instanceId, canvasNodes, canvasConnections, currentWorkflowId, promptInput, invocations, t]);
|
||||
}, [request, instanceId, canvasNodes, canvasConnections, currentWorkflowId, promptInput, invocations, t, nodeErrors]);
|
||||
|
||||
const handleLoad = useCallback(
|
||||
async (workflowId: string) => {
|
||||
|
|
@ -608,9 +666,11 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({ in
|
|||
await updateWorkflow(request, instanceId, workflowId, { label: newName });
|
||||
setWorkflows((prev) => prev.map((w) => w.id === workflowId ? { ...w, label: newName } : w));
|
||||
} catch (e: unknown) {
|
||||
const msg = e instanceof Error ? e.message : String(e);
|
||||
console.error(`${LOG} rename failed`, e);
|
||||
showError(t('Workflow umbenennen fehlgeschlagen: {msg}', { msg }));
|
||||
}
|
||||
}, [request, instanceId]);
|
||||
}, [request, instanceId, showError, t]);
|
||||
|
||||
const handleAutoLayout = useCallback(() => {
|
||||
setCanvasNodes((prev) => computeAutoLayout(prev, canvasConnections));
|
||||
|
|
@ -749,6 +809,17 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({ in
|
|||
saving={saving}
|
||||
executing={executing}
|
||||
hasNodes={canvasNodes.length > 0}
|
||||
executeBlockedReason={
|
||||
hasGraphErrors
|
||||
? t('Pflicht-Felder ohne Quelle vorhanden. Klicken markiert die erste betroffene Node.')
|
||||
: null
|
||||
}
|
||||
onExecuteBlockedClick={() => {
|
||||
if (firstErrorNodeId) {
|
||||
const n = canvasNodes.find((x) => x.id === firstErrorNodeId);
|
||||
if (n) setSelectedNode(n);
|
||||
}
|
||||
}}
|
||||
executeResult={executeResult}
|
||||
versions={versions}
|
||||
currentVersionId={currentVersionId}
|
||||
|
|
@ -763,6 +834,8 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({ in
|
|||
onNewFromTemplate={() => setTemplatePickerOpen(true)}
|
||||
onWorkflowRename={handleWorkflowRename}
|
||||
onAutoLayout={handleAutoLayout}
|
||||
verboseSchema={verboseSchema}
|
||||
onVerboseSchemaChange={setVerboseSchema}
|
||||
/>
|
||||
<div className={styles.canvasArea} style={{ display: 'flex', flex: 1, minWidth: 0 }}>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
|
|
@ -777,6 +850,7 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({ in
|
|||
getCategoryIcon={getCategoryIcon}
|
||||
onSelectionChange={setSelectedNode}
|
||||
highlightedNodeIds={tracingRunId ? tracingNodeStatuses : undefined}
|
||||
nodeErrors={nodeErrors}
|
||||
onExternalDrop={async (mime, payload) => {
|
||||
if (mime !== 'application/json+workflow' || !instanceId) return false;
|
||||
const p = payload as { files?: Array<{ id: string }> } | undefined;
|
||||
|
|
@ -804,6 +878,8 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({ in
|
|||
language={language}
|
||||
portTypeCatalog={portTypeCatalog as Record<string, never>}
|
||||
systemVariables={systemVariables as Record<string, never>}
|
||||
instanceId={instanceId}
|
||||
request={request}
|
||||
>
|
||||
<NodeConfigPanel
|
||||
node={selectedNode}
|
||||
|
|
@ -814,6 +890,7 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({ in
|
|||
onNodeUpdate={handleNodeUpdate}
|
||||
instanceId={instanceId}
|
||||
request={request}
|
||||
verboseSchema={verboseSchema}
|
||||
/>
|
||||
</Automation2DataFlowProvider>
|
||||
)}
|
||||
|
|
|
|||
99
src/components/FlowEditor/editor/CanvasHeader.test.tsx
Normal file
99
src/components/FlowEditor/editor/CanvasHeader.test.tsx
Normal file
|
|
@ -0,0 +1,99 @@
|
|||
// Copyright (c) 2025 Patrick Motsch
|
||||
// All rights reserved.
|
||||
//
|
||||
// Plan #2 — Track A1.4 (T10): CanvasHeader Run-button gating logic.
|
||||
// Verifies the AC-9 patch — Save always enabled (unless saving), Run blocked
|
||||
// when executeBlockedReason is set + warning toast surfaced as amber banner.
|
||||
|
||||
import React from 'react';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import type { Automation2Workflow, ExecuteGraphResponse } from '../../../api/workflowApi';
|
||||
|
||||
vi.mock('../../../providers/language/LanguageContext', () => ({
|
||||
useLanguage: () => ({ t: (s: string) => s }),
|
||||
}));
|
||||
|
||||
import { CanvasHeader } from './CanvasHeader';
|
||||
|
||||
const _workflows: Automation2Workflow[] = [];
|
||||
|
||||
function _renderHeader(overrides: Partial<React.ComponentProps<typeof CanvasHeader>> = {}) {
|
||||
const props: React.ComponentProps<typeof CanvasHeader> = {
|
||||
workflows: _workflows,
|
||||
currentWorkflowId: null,
|
||||
onWorkflowSelect: () => {},
|
||||
onNew: () => {},
|
||||
onSave: () => {},
|
||||
onExecute: () => {},
|
||||
saving: false,
|
||||
executing: false,
|
||||
hasNodes: true,
|
||||
executeResult: null,
|
||||
...overrides,
|
||||
};
|
||||
return render(<CanvasHeader {...props} />);
|
||||
}
|
||||
|
||||
describe('CanvasHeader Run-button (T10)', () => {
|
||||
it('runs `onExecute` when not blocked', async () => {
|
||||
const onExecute = vi.fn();
|
||||
_renderHeader({ onExecute });
|
||||
await userEvent.click(screen.getByRole('button', { name: /Ausführen/i }));
|
||||
expect(onExecute).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('shows the "Pflicht-Felder fehlen" label and triggers `onExecuteBlockedClick` instead of `onExecute`', async () => {
|
||||
const onExecute = vi.fn();
|
||||
const onExecuteBlockedClick = vi.fn();
|
||||
_renderHeader({
|
||||
onExecute,
|
||||
onExecuteBlockedClick,
|
||||
executeBlockedReason: '2 Nodes mit Pflicht-Fehlern',
|
||||
});
|
||||
const btn = screen.getByRole('button', { name: /Pflicht-Felder fehlen/i });
|
||||
expect(btn).toHaveAttribute('aria-disabled', 'true');
|
||||
expect(btn).toHaveAttribute('title', '2 Nodes mit Pflicht-Fehlern');
|
||||
await userEvent.click(btn);
|
||||
expect(onExecute).not.toHaveBeenCalled();
|
||||
expect(onExecuteBlockedClick).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('disables the Run button while executing or when no nodes are present', () => {
|
||||
const { rerender } = _renderHeader({ executing: true });
|
||||
expect(screen.getByRole('button', { name: /Ausführen…/i })).toBeDisabled();
|
||||
rerender(
|
||||
<CanvasHeader
|
||||
workflows={_workflows}
|
||||
currentWorkflowId={null}
|
||||
onWorkflowSelect={() => {}}
|
||||
onNew={() => {}}
|
||||
onSave={() => {}}
|
||||
onExecute={() => {}}
|
||||
saving={false}
|
||||
executing={false}
|
||||
hasNodes={false}
|
||||
executeResult={null}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByRole('button', { name: /Ausführen/i })).toBeDisabled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('CanvasHeader executeResult banner (AC-9)', () => {
|
||||
it('renders the warning text in amber when success+warning is present', () => {
|
||||
const result: ExecuteGraphResponse = {
|
||||
success: true,
|
||||
warning: 'Gespeichert mit 3 Pflicht-Fehlern in 2 Nodes.',
|
||||
};
|
||||
_renderHeader({ executeResult: result });
|
||||
expect(screen.getByText(/Gespeichert mit 3 Pflicht-Fehlern/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders the error text in red when success=false', () => {
|
||||
const result: ExecuteGraphResponse = { success: false, error: 'Boom' };
|
||||
_renderHeader({ executeResult: result });
|
||||
expect(screen.getByText(/Boom/)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
|
@ -8,6 +8,7 @@ import type { Automation2Workflow, ExecuteGraphResponse, AutoVersion, AutoTempla
|
|||
import styles from './Automation2FlowEditor.module.css';
|
||||
|
||||
import { useLanguage } from '../../../providers/language/LanguageContext';
|
||||
import { getUserDataCache } from '../../../utils/userCache';
|
||||
|
||||
interface CanvasHeaderProps {
|
||||
workflows: Automation2Workflow[];
|
||||
|
|
@ -21,6 +22,11 @@ interface CanvasHeaderProps {
|
|||
saving: boolean;
|
||||
executing: boolean;
|
||||
hasNodes: boolean;
|
||||
/** Phase-4 Schicht-4: when set, the Run button is disabled and the message
|
||||
* is shown as a tooltip. Click triggers `onExecuteBlockedClick` so the
|
||||
* parent can navigate the user to the first offending node. */
|
||||
executeBlockedReason?: string | null;
|
||||
onExecuteBlockedClick?: () => void;
|
||||
executeResult: ExecuteGraphResponse | null;
|
||||
versions?: AutoVersion[];
|
||||
currentVersionId?: string | null;
|
||||
|
|
@ -35,6 +41,10 @@ interface CanvasHeaderProps {
|
|||
onNewFromTemplate?: () => void;
|
||||
onWorkflowRename?: (workflowId: string, newName: string) => void;
|
||||
onAutoLayout?: () => void;
|
||||
/** Sysadmin-only: when true, NodeConfigPanel renders the static
|
||||
* "Schema (Typ-Referenz)" block and per-parameter type-badges. */
|
||||
verboseSchema?: boolean;
|
||||
onVerboseSchemaChange?: (next: boolean) => void;
|
||||
}
|
||||
|
||||
function _getStatusBadge(t: (key: string) => string): Record<string, { label: string; color: string }> {
|
||||
|
|
@ -56,6 +66,8 @@ export const CanvasHeader: React.FC<CanvasHeaderProps> = ({ workflows,
|
|||
saving,
|
||||
executing,
|
||||
hasNodes,
|
||||
executeBlockedReason,
|
||||
onExecuteBlockedClick,
|
||||
executeResult,
|
||||
versions,
|
||||
currentVersionId,
|
||||
|
|
@ -70,8 +82,11 @@ export const CanvasHeader: React.FC<CanvasHeaderProps> = ({ workflows,
|
|||
onNewFromTemplate,
|
||||
onWorkflowRename,
|
||||
onAutoLayout,
|
||||
verboseSchema,
|
||||
onVerboseSchemaChange,
|
||||
}) => {
|
||||
const { t } = useLanguage();
|
||||
const _isSysAdmin = getUserDataCache()?.isSysAdmin === true;
|
||||
const statusBadge = _getStatusBadge(t);
|
||||
const currentVersion = versions?.find((v) => v.id === currentVersionId);
|
||||
const currentStatus = currentVersion?.status || 'draft';
|
||||
|
|
@ -130,185 +145,244 @@ export const CanvasHeader: React.FC<CanvasHeaderProps> = ({ workflows,
|
|||
[t]
|
||||
);
|
||||
|
||||
const _titleHint =
|
||||
onWorkflowRename && currentWorkflow
|
||||
? `${currentWorkflow.label} — ${t('Klicken zum Umbenennen')}`
|
||||
: currentWorkflow?.label;
|
||||
|
||||
return (
|
||||
<div className={styles.canvasHeader}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.75rem', flexWrap: 'wrap' }}>
|
||||
{/* Workflow name: inline editable */}
|
||||
{currentWorkflowId && currentWorkflow ? (
|
||||
editingName ? (
|
||||
<input
|
||||
ref={nameInputRef}
|
||||
value={nameValue}
|
||||
onChange={(e) => setNameValue(e.target.value)}
|
||||
onBlur={_commitNameEdit}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter') _commitNameEdit(); if (e.key === 'Escape') setEditingName(false); }}
|
||||
style={{ padding: '0.25rem 0.4rem', fontSize: '0.95rem', fontWeight: 600, border: '1px solid var(--primary-color, #007bff)', borderRadius: 4, outline: 'none', minWidth: 140, maxWidth: 300 }}
|
||||
/>
|
||||
) : (
|
||||
<h4
|
||||
className={styles.canvasTitle}
|
||||
style={{ margin: 0, cursor: onWorkflowRename ? 'pointer' : 'default', fontSize: '0.95rem', fontWeight: 600 }}
|
||||
onClick={_startNameEdit}
|
||||
title={onWorkflowRename ? t('Klicken zum Umbenennen') : undefined}
|
||||
>
|
||||
{currentWorkflow.label}
|
||||
</h4>
|
||||
)
|
||||
) : (
|
||||
<h4 className={styles.canvasTitle} style={{ margin: 0, fontStyle: 'italic', opacity: 0.6 }}>
|
||||
{t('Neuer Workflow')}
|
||||
</h4>
|
||||
)}
|
||||
{onWorkflowSettings && (
|
||||
<button
|
||||
type="button"
|
||||
className={styles.canvasGearBtn}
|
||||
title={t('Workflowkonfiguration Einstieg/Starts')}
|
||||
aria-label={t('Workflow-Konfiguration')}
|
||||
onClick={onWorkflowSettings}
|
||||
<div className={styles.canvasHeaderRow}>
|
||||
<div className={styles.canvasHeaderContext}>
|
||||
<select
|
||||
className={styles.canvasHeaderWorkflowSelect}
|
||||
value={currentWorkflowId ?? ''}
|
||||
onChange={(e) => {
|
||||
const id = e.target.value ? e.target.value : null;
|
||||
onWorkflowSelect(id);
|
||||
}}
|
||||
aria-label={t('Workflow laden')}
|
||||
title={t('Workflow laden')}
|
||||
>
|
||||
<FaCog />
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Split "Neu" button */}
|
||||
<div ref={newMenuRef} style={{ position: 'relative', display: 'inline-block' }}>
|
||||
<div style={{ display: 'flex' }}>
|
||||
<button type="button" className={styles.retryButton} onClick={onNew} style={{ borderTopRightRadius: 0, borderBottomRightRadius: 0 }}>
|
||||
{t('Neu')}
|
||||
</button>
|
||||
<option value="">{t('Workflow laden')}</option>
|
||||
{workflows.map((w) => (
|
||||
<option key={w.id} value={w.id}>
|
||||
{w.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<div className={styles.canvasHeaderTitleBlock}>
|
||||
{currentWorkflowId && currentWorkflow ? (
|
||||
editingName ? (
|
||||
<input
|
||||
ref={nameInputRef}
|
||||
className={styles.canvasHeaderTitle}
|
||||
value={nameValue}
|
||||
onChange={(e) => setNameValue(e.target.value)}
|
||||
onBlur={_commitNameEdit}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter') _commitNameEdit(); if (e.key === 'Escape') setEditingName(false); }}
|
||||
/>
|
||||
) : (
|
||||
<h4
|
||||
className={styles.canvasHeaderTitle}
|
||||
style={{ cursor: onWorkflowRename ? 'pointer' : 'default' }}
|
||||
onClick={_startNameEdit}
|
||||
title={_titleHint}
|
||||
>
|
||||
{currentWorkflow.label}
|
||||
</h4>
|
||||
)
|
||||
) : (
|
||||
<h4 className={`${styles.canvasHeaderTitle} ${styles.canvasHeaderTitleMuted}`}>
|
||||
{t('Neuer Workflow')}
|
||||
</h4>
|
||||
)}
|
||||
</div>
|
||||
{onWorkflowSettings && (
|
||||
<button
|
||||
type="button"
|
||||
className={styles.retryButton}
|
||||
onClick={() => setNewMenuOpen((p) => !p)}
|
||||
style={{ borderTopLeftRadius: 0, borderBottomLeftRadius: 0, paddingLeft: 4, paddingRight: 6, borderLeft: '1px solid rgba(0,0,0,0.15)' }}
|
||||
title={t('Neu aus Vorlage')}
|
||||
className={styles.canvasGearBtn}
|
||||
title={t('Workflowkonfiguration Einstieg/Starts')}
|
||||
aria-label={t('Workflow-Konfiguration')}
|
||||
onClick={onWorkflowSettings}
|
||||
>
|
||||
<FaCaretDown style={{ fontSize: '0.7rem' }} />
|
||||
<FaCog />
|
||||
</button>
|
||||
</div>
|
||||
{newMenuOpen && (
|
||||
<div style={{ position: 'absolute', top: '100%', left: 0, zIndex: 100, background: 'var(--bg-primary, #fff)', border: '1px solid var(--border-color, #e0e0e0)', borderRadius: 6, boxShadow: '0 4px 12px rgba(0,0,0,0.15)', minWidth: 180, marginTop: 4 }}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { onNew(); setNewMenuOpen(false); }}
|
||||
style={{ display: 'block', width: '100%', textAlign: 'left', padding: '8px 12px', border: 'none', background: 'transparent', cursor: 'pointer', fontSize: '0.85rem' }}
|
||||
>
|
||||
{t('Leerer Workflow')}
|
||||
</button>
|
||||
{onNewFromTemplate && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { onNewFromTemplate(); setNewMenuOpen(false); }}
|
||||
style={{ display: 'block', width: '100%', textAlign: 'left', padding: '8px 12px', border: 'none', background: 'transparent', cursor: 'pointer', fontSize: '0.85rem', borderTop: '1px solid var(--border-color, #e0e0e0)' }}
|
||||
>
|
||||
{t('Aus Vorlage…')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className={styles.retryButton}
|
||||
onClick={onSave}
|
||||
disabled={saving || !hasNodes}
|
||||
>
|
||||
{saving ? <FaSpinner className={styles.spinner} /> : t('Speichern')}
|
||||
</button>
|
||||
|
||||
{onAutoLayout && (
|
||||
<button
|
||||
type="button"
|
||||
className={styles.retryButton}
|
||||
onClick={onAutoLayout}
|
||||
disabled={!hasNodes}
|
||||
title={t('Knoten automatisch anordnen')}
|
||||
>
|
||||
<FaSitemap style={{ marginRight: '0.4rem' }} />
|
||||
{t('Anordnen')}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Save as template */}
|
||||
{currentWorkflowId && onSaveAsTemplate && (
|
||||
<div ref={templateMenuRef} style={{ position: 'relative', display: 'inline-block' }}>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.retryButton}
|
||||
onClick={() => setTemplateMenuOpen((p) => !p)}
|
||||
disabled={templateSaving}
|
||||
title={t('Als Vorlage speichern')}
|
||||
>
|
||||
{templateSaving ? <FaSpinner className={styles.spinner} /> : <><FaBookmark style={{ marginRight: 4 }} />{t('Als Vorlage')}</>}
|
||||
</button>
|
||||
{templateMenuOpen && (
|
||||
<div style={{ position: 'absolute', top: '100%', left: 0, zIndex: 100, background: 'var(--bg-primary, #fff)', border: '1px solid var(--border-color, #e0e0e0)', borderRadius: 6, boxShadow: '0 4px 12px rgba(0,0,0,0.15)', minWidth: 180, marginTop: 4 }}>
|
||||
{(['user', 'instance', 'mandate'] as const).map((s) => (
|
||||
<div className={styles.canvasHeaderActionPanel} role="toolbar" aria-label={t('Workflow-Aktionen')}>
|
||||
<div ref={newMenuRef} className={styles.canvasHeaderNewSplit}>
|
||||
<div className={styles.canvasHeaderSplitPair}>
|
||||
<button
|
||||
type="button"
|
||||
className={`${styles.retryButton} ${styles.canvasHeaderNewSplitMain}`}
|
||||
onClick={onNew}
|
||||
>
|
||||
{t('Neu')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`${styles.retryButton} ${styles.canvasHeaderNewSplitMenu}`}
|
||||
onClick={() => setNewMenuOpen((p) => !p)}
|
||||
title={t('Neu aus Vorlage')}
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={newMenuOpen}
|
||||
>
|
||||
<FaCaretDown style={{ fontSize: '0.7rem' }} />
|
||||
</button>
|
||||
</div>
|
||||
{newMenuOpen && (
|
||||
<div className={styles.canvasHeaderMenuDropdown} role="menu">
|
||||
<button
|
||||
type="button"
|
||||
className={styles.canvasHeaderMenuItem}
|
||||
onClick={() => { onNew(); setNewMenuOpen(false); }}
|
||||
role="menuitem"
|
||||
>
|
||||
{t('Leerer Workflow')}
|
||||
</button>
|
||||
{onNewFromTemplate && (
|
||||
<button
|
||||
key={s}
|
||||
type="button"
|
||||
onClick={() => { onSaveAsTemplate(s); setTemplateMenuOpen(false); }}
|
||||
style={{ display: 'block', width: '100%', textAlign: 'left', padding: '8px 12px', border: 'none', background: 'transparent', cursor: 'pointer', fontSize: '0.85rem', borderTop: s !== 'user' ? '1px solid var(--border-color, #e0e0e0)' : undefined }}
|
||||
className={styles.canvasHeaderMenuItem}
|
||||
onClick={() => { onNewFromTemplate(); setNewMenuOpen(false); }}
|
||||
role="menuitem"
|
||||
>
|
||||
{scopeLabels[s]}
|
||||
{t('Aus Vorlage…')}
|
||||
</button>
|
||||
))}
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<select
|
||||
value={currentWorkflowId ?? ''}
|
||||
onChange={(e) => {
|
||||
const id = e.target.value ? e.target.value : null;
|
||||
onWorkflowSelect(id);
|
||||
}}
|
||||
style={{ padding: '0.4rem', minWidth: 180 }}
|
||||
>
|
||||
<option value="">{t('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' }} />
|
||||
{t('Ausführen…')}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<FaPlay style={{ marginRight: '0.5rem' }} />
|
||||
{t('Ausführen')}
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
{onToggleChat && (
|
||||
<button type="button" className={styles.retryButton} onClick={onToggleChat} title={t('Workspace-Panel: Chats, Dateien, Quellen')}>
|
||||
<FaDatabase style={{ marginRight: '0.4rem' }} />
|
||||
{t('Workspace')}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className={styles.retryButton}
|
||||
onClick={onSave}
|
||||
disabled={saving}
|
||||
title={!hasNodes ? t('Workflow ist leer — Speichern legt einen leeren Workflow an.') : undefined}
|
||||
>
|
||||
{saving ? <FaSpinner className={styles.spinner} /> : t('Speichern')}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{onAutoLayout && (
|
||||
<button
|
||||
type="button"
|
||||
className={styles.retryButton}
|
||||
onClick={onAutoLayout}
|
||||
disabled={!hasNodes}
|
||||
title={t('Knoten automatisch anordnen')}
|
||||
>
|
||||
<FaSitemap style={{ marginRight: '0.4rem' }} />
|
||||
{t('Anordnen')}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{currentWorkflowId && onSaveAsTemplate && (
|
||||
<div ref={templateMenuRef} className={styles.canvasHeaderNewSplit}>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.retryButton}
|
||||
onClick={() => setTemplateMenuOpen((p) => !p)}
|
||||
disabled={templateSaving}
|
||||
title={t('Als Vorlage speichern')}
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={templateMenuOpen}
|
||||
>
|
||||
{templateSaving ? <FaSpinner className={styles.spinner} /> : <><FaBookmark style={{ marginRight: 4 }} />{t('Als Vorlage')}</>}
|
||||
</button>
|
||||
{templateMenuOpen && (
|
||||
<div className={styles.canvasHeaderMenuDropdown} role="menu">
|
||||
{(['user', 'instance', 'mandate'] as const).map((s) => (
|
||||
<button
|
||||
key={s}
|
||||
type="button"
|
||||
className={styles.canvasHeaderMenuItem}
|
||||
onClick={() => { onSaveAsTemplate(s); setTemplateMenuOpen(false); }}
|
||||
role="menuitem"
|
||||
>
|
||||
{scopeLabels[s]}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className={`${styles.retryButton} ${styles.canvasHeaderRunButton}`}
|
||||
onClick={() => {
|
||||
if (executeBlockedReason) {
|
||||
onExecuteBlockedClick?.();
|
||||
return;
|
||||
}
|
||||
onExecute();
|
||||
}}
|
||||
disabled={executing || !hasNodes}
|
||||
aria-disabled={executing || !hasNodes || !!executeBlockedReason}
|
||||
title={executeBlockedReason ?? undefined}
|
||||
style={
|
||||
executeBlockedReason
|
||||
? {
|
||||
background: 'rgba(220,53,69,0.10)',
|
||||
borderColor: 'var(--danger-color, #dc3545)',
|
||||
color: 'var(--danger-color, #dc3545)',
|
||||
cursor: 'help',
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{executing ? (
|
||||
<>
|
||||
<FaSpinner className={styles.spinner} style={{ flexShrink: 0 }} />
|
||||
{t('Ausführen…')}
|
||||
</>
|
||||
) : executeBlockedReason ? (
|
||||
<>
|
||||
<FaPlay style={{ opacity: 0.5, flexShrink: 0 }} />
|
||||
{t('Pflicht-Felder fehlen')}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<FaPlay style={{ flexShrink: 0 }} />
|
||||
{t('Ausführen')}
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
{onToggleChat && (
|
||||
<button type="button" className={styles.retryButton} onClick={onToggleChat} title={t('Workspace-Panel: Chats, Dateien, Quellen')}>
|
||||
<FaDatabase style={{ marginRight: '0.4rem' }} />
|
||||
{t('Workspace')}
|
||||
</button>
|
||||
)}
|
||||
{_isSysAdmin && onVerboseSchemaChange && (
|
||||
<label
|
||||
className={styles.canvasHeaderSysadmin}
|
||||
title={t('Sysadmin-Ansicht: zeigt im Node-Panel das statische Typ-Schema (Eingabe/Ausgabe) und Parameter-Typ-Badges.')}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={!!verboseSchema}
|
||||
onChange={(e) => onVerboseSchemaChange(e.target.checked)}
|
||||
style={{ margin: 0 }}
|
||||
/>
|
||||
{t('Schema-Details')}
|
||||
</label>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Version Selector */}
|
||||
{currentWorkflowId && versions && versions.length > 0 && (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', marginTop: '0.5rem', flexWrap: 'wrap' }}>
|
||||
<span style={{ fontSize: '0.8rem', fontWeight: 600, color: 'var(--text-secondary, #666)' }}>{t('Version:')}</span>
|
||||
<div className={styles.canvasHeaderVersionRow}>
|
||||
<span className={styles.canvasHeaderVersionLabel}>{t('Version:')}</span>
|
||||
<select
|
||||
className={styles.canvasHeaderVersionSelect}
|
||||
value={currentVersionId ?? ''}
|
||||
onChange={(e) => onVersionSelect?.(e.target.value || null)}
|
||||
style={{ padding: '0.3rem', minWidth: 140, fontSize: '0.85rem' }}
|
||||
disabled={versionLoading}
|
||||
aria-label={t('Version')}
|
||||
>
|
||||
<option value="">{t('Aktuelle')}</option>
|
||||
{versions.map((v) => (
|
||||
|
|
@ -392,19 +466,27 @@ export const CanvasHeader: React.FC<CanvasHeaderProps> = ({ workflows,
|
|||
borderRadius: 6,
|
||||
fontSize: '0.875rem',
|
||||
background: executeResult.success
|
||||
? 'rgba(40,167,69,0.15)'
|
||||
? executeResult.warning
|
||||
? 'rgba(255,193,7,0.15)'
|
||||
: '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.warning
|
||||
? 'var(--warning-color,#ffc107)'
|
||||
: 'var(--success-color,#28a745)'
|
||||
: (executeResult as { paused?: boolean }).paused
|
||||
? 'var(--primary-color,#007bff)'
|
||||
: 'var(--danger-color,#dc3545)',
|
||||
}}
|
||||
>
|
||||
{executeResult.success ? (
|
||||
<>{t('Ausführung abgeschlossen')}</>
|
||||
executeResult.warning ? (
|
||||
<>⚠ {executeResult.warning}</>
|
||||
) : (
|
||||
<>{t('Ausführung abgeschlossen')}</>
|
||||
)
|
||||
) : (executeResult as { paused?: boolean }).paused ? (
|
||||
<>
|
||||
⏸ Workflow pausiert. Öffne <strong>{t('Workflows/Tasks')}</strong> in der Sidebar, um den
|
||||
|
|
|
|||
|
|
@ -48,7 +48,7 @@ export const EditorWorkflowChatList: React.FC<EditorWorkflowChatListProps> = ({
|
|||
const list = q
|
||||
? workflows.filter((w) => (w.label || '').toLowerCase().includes(q))
|
||||
: [...workflows];
|
||||
list.sort((a, b) => (b.lastStartedAt || b.createdAt || 0) - (a.lastStartedAt || a.createdAt || 0));
|
||||
list.sort((a, b) => (b.lastStartedAt || b.sysCreatedAt || 0) - (a.lastStartedAt || a.sysCreatedAt || 0));
|
||||
return list;
|
||||
}, [workflows, search]);
|
||||
|
||||
|
|
@ -85,7 +85,7 @@ export const EditorWorkflowChatList: React.FC<EditorWorkflowChatListProps> = ({
|
|||
) : (
|
||||
filtered.map((wf) => {
|
||||
const isActive = wf.id === currentWorkflowId;
|
||||
const ts = wf.lastStartedAt || wf.createdAt;
|
||||
const ts = wf.lastStartedAt || wf.sysCreatedAt;
|
||||
return (
|
||||
<div
|
||||
key={wf.id}
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@
|
|||
*/
|
||||
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import type { NodeType } from '../../../api/workflowApi';
|
||||
import type { GraphDefinedSchemaRef, NodeType } from '../../../api/workflowApi';
|
||||
import styles from './Automation2FlowEditor.module.css';
|
||||
|
||||
import { useLanguage } from '../../../providers/language/LanguageContext';
|
||||
|
|
@ -23,7 +23,7 @@ export interface CanvasNode {
|
|||
outputs: number;
|
||||
parameters?: Record<string, unknown>;
|
||||
inputPorts?: Array<{ name: string; schema: string; accepts?: string[] }>;
|
||||
outputPorts?: Array<{ name: string; schema: string }>;
|
||||
outputPorts?: Array<{ name: string; schema: string | GraphDefinedSchemaRef }>;
|
||||
}
|
||||
|
||||
export interface CanvasConnection {
|
||||
|
|
@ -108,6 +108,12 @@ export function computeAutoLayout(
|
|||
});
|
||||
}
|
||||
|
||||
function _outputSchemaName(schema: string | GraphDefinedSchemaRef | undefined): string {
|
||||
if (typeof schema === 'string') return schema;
|
||||
if (schema && typeof schema === 'object' && schema.kind === 'fromGraph') return 'FormPayload';
|
||||
return '';
|
||||
}
|
||||
|
||||
/** Soft port compatibility check: returns 'ok' | 'warning' | 'error' */
|
||||
function _checkConnectionCompatibility(
|
||||
sourceNode: CanvasNode,
|
||||
|
|
@ -124,11 +130,12 @@ function _checkConnectionCompatibility(
|
|||
const tgtPort = tgtType.inputPorts[targetInputIdx];
|
||||
if (!srcPort || !tgtPort) return 'ok';
|
||||
|
||||
const srcSchema = srcPort.schema;
|
||||
const srcSchema = _outputSchemaName(srcPort.schema as string | GraphDefinedSchemaRef);
|
||||
const accepts = tgtPort.accepts;
|
||||
if (!accepts || accepts.length === 0) return 'ok';
|
||||
if (accepts.includes('Transit')) return 'ok';
|
||||
if (accepts.includes(srcSchema)) return 'ok';
|
||||
if (srcSchema && accepts.includes(srcSchema)) return 'ok';
|
||||
if (srcSchema?.startsWith('FormPayload') && accepts.includes('FormPayload')) return 'ok';
|
||||
return 'warning';
|
||||
}
|
||||
|
||||
|
|
@ -143,6 +150,9 @@ interface FlowCanvasProps {
|
|||
getCategoryIcon: (category: string) => React.ReactNode;
|
||||
onSelectionChange?: (node: CanvasNode | null) => void;
|
||||
highlightedNodeIds?: Record<string, string>;
|
||||
/** Phase-4: per-node "required-but-unbound" param errors. The canvas renders
|
||||
* a red error badge in the top-right of each node whose id is a key. */
|
||||
nodeErrors?: Record<string, Array<{ paramName: string; paramLabel: string }>>;
|
||||
/** Wenn ein Drop mit einer registrierten externen MIME-Type ankommt
|
||||
* (z. B. ``application/json+workflow`` aus der UDB-FilesTab),
|
||||
* wird dieser Callback statt der Node-Type-Drop-Logik aufgerufen.
|
||||
|
|
@ -167,6 +177,7 @@ export const FlowCanvas: React.FC<FlowCanvasProps> = ({ nodes,
|
|||
getCategoryIcon,
|
||||
onSelectionChange,
|
||||
highlightedNodeIds,
|
||||
nodeErrors,
|
||||
onExternalDrop,
|
||||
}) => {
|
||||
const { t } = useLanguage();
|
||||
|
|
@ -790,6 +801,9 @@ export const FlowCanvas: React.FC<FlowCanvasProps> = ({ nodes,
|
|||
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 wireSourceNode =
|
||||
connectingFrom && !selectedConnectionId ? nodes.find((n) => n.id === connectingFrom.nodeId) : null;
|
||||
|
||||
const isSelected = selectedNodeIds.has(node.id);
|
||||
const isEditingTitle = editingNodeId === node.id && editingField === 'title';
|
||||
const displayTitle = node.title ?? node.label ?? getLabel(node);
|
||||
|
|
@ -834,15 +848,54 @@ export const FlowCanvas: React.FC<FlowCanvasProps> = ({ nodes,
|
|||
title={t('Dieser Schritt nutzt AI und verbraucht Credits')}
|
||||
/>
|
||||
)}
|
||||
{nodeErrors?.[node.id]?.length ? (
|
||||
<div
|
||||
role="status"
|
||||
title={
|
||||
t('Pflicht-Felder ohne Quelle: ') +
|
||||
nodeErrors[node.id].map((e) => e.paramLabel).join(', ')
|
||||
}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: -8,
|
||||
right: -8,
|
||||
minWidth: 20,
|
||||
height: 20,
|
||||
borderRadius: 10,
|
||||
padding: '0 6px',
|
||||
background: 'var(--danger-color, #dc3545)',
|
||||
color: '#fff',
|
||||
fontSize: 11,
|
||||
fontWeight: 700,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
boxShadow: '0 1px 3px rgba(0,0,0,0.25)',
|
||||
zIndex: 5,
|
||||
pointerEvents: 'auto',
|
||||
}}
|
||||
>
|
||||
{nodeErrors[node.id].length}
|
||||
</div>
|
||||
) : null}
|
||||
{handles.map(({ index, isOutput }) => {
|
||||
const pos = getHandlePosition(node, index);
|
||||
const used = !isOutput && getUsedTargetHandles.has(`${node.id}-${index}`);
|
||||
const selConn = selectedConnectionId ? connections.find((c) => c.id === selectedConnectionId) : null;
|
||||
const isCurrentTargetOfSelection =
|
||||
selConn && selConn.targetId === node.id && selConn.targetHandle === index;
|
||||
let wireTargetOk = true;
|
||||
if (!isOutput && connectingFrom && !selectedConnectionId && wireSourceNode) {
|
||||
const sourceOutputIdx =
|
||||
connectingFrom.handleIndex >= wireSourceNode.inputs
|
||||
? connectingFrom.handleIndex - wireSourceNode.inputs
|
||||
: 0;
|
||||
wireTargetOk =
|
||||
_checkConnectionCompatibility(wireSourceNode, sourceOutputIdx, node, index, nodeTypes) === 'ok';
|
||||
}
|
||||
const canConnect =
|
||||
isOutput ||
|
||||
(!used && connectingFrom) ||
|
||||
(!used && !!connectingFrom && (!selectedConnectionId ? wireTargetOk : true)) ||
|
||||
(!!selectedConnectionId && !isOutput && (!used || isCurrentTargetOfSelection));
|
||||
const nt = nodeTypeMap[node.type];
|
||||
const outputLabel = isOutput && nt?.outputLabels ? nt.outputLabels[index - node.inputs] : undefined;
|
||||
|
|
|
|||
|
|
@ -3,12 +3,14 @@
|
|||
* Renders each parameter using FRONTEND_TYPE_RENDERERS based on frontendType.
|
||||
*/
|
||||
|
||||
import React, { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import React, { useState, useEffect, useCallback, useMemo, useRef } from 'react';
|
||||
import type { CanvasNode } from './FlowCanvas';
|
||||
import type { NodeType, NodeTypeParameter, PortSchema } from '../../../api/workflowApi';
|
||||
import type { GraphDefinedSchemaRef, NodeType, NodeTypeParameter, PortSchema } from '../../../api/workflowApi';
|
||||
import type { ApiRequestFunction } from '../../../api/workflowApi';
|
||||
import { getLabel } from '../nodes/shared/utils';
|
||||
import { FRONTEND_TYPE_RENDERERS } from '../nodes/frontendTypeRenderers';
|
||||
import { RequiredAttributePicker } from '../nodes/shared/RequiredAttributePicker';
|
||||
import { findRequiredErrors } from '../nodes/shared/paramValidation';
|
||||
import { useAutomation2DataFlow } from '../context/Automation2DataFlowContext';
|
||||
import styles from './Automation2FlowEditor.module.css';
|
||||
|
||||
|
|
@ -23,6 +25,9 @@ interface NodeConfigPanelProps {
|
|||
onNodeUpdate?: (nodeId: string, updates: Partial<Pick<CanvasNode, 'title' | 'comment'>>) => void;
|
||||
instanceId?: string;
|
||||
request?: ApiRequestFunction;
|
||||
/** When true, render developer-oriented sections (Schema-Typ-Referenz,
|
||||
* parameter type-badges). Toggle in CanvasHeader, sysadmin-only. */
|
||||
verboseSchema?: boolean;
|
||||
}
|
||||
|
||||
export const NodeConfigPanel: React.FC<NodeConfigPanelProps> = ({ node,
|
||||
|
|
@ -33,6 +38,7 @@ export const NodeConfigPanel: React.FC<NodeConfigPanelProps> = ({ node,
|
|||
onNodeUpdate,
|
||||
instanceId,
|
||||
request,
|
||||
verboseSchema = false,
|
||||
}) => {
|
||||
const { t } = useLanguage();
|
||||
const [params, setParams] = useState<Record<string, unknown>>({});
|
||||
|
|
@ -76,11 +82,44 @@ export const NodeConfigPanel: React.FC<NodeConfigPanelProps> = ({ node,
|
|||
const dataFlow = useAutomation2DataFlow();
|
||||
const portTypeCatalog: Record<string, PortSchema> = (dataFlow?.portTypeCatalog as Record<string, PortSchema> | undefined) ?? {};
|
||||
|
||||
// Phase-4 Schicht-4 — Pflicht-Params zuerst sortieren, damit der User
|
||||
// nicht nach unten scrollen muss, um zu sehen was fehlt.
|
||||
const sortedParameters: NodeTypeParameter[] = useMemo(() => {
|
||||
const all = nodeType?.parameters ?? [];
|
||||
const required = all.filter((p) => p.required);
|
||||
const optional = all.filter((p) => !p.required);
|
||||
return [...required, ...optional];
|
||||
}, [nodeType?.parameters]);
|
||||
|
||||
// Pre-compute which required params are unbound on this node so we can
|
||||
// surface a panel-level summary banner. The hidden-param safety net lives
|
||||
// inside `findRequiredErrors` so banner, canvas badges and Run-button stay
|
||||
// in lockstep.
|
||||
// Banner labels are kept short (`param.name`); the full description is
|
||||
// attached as the tooltip below.
|
||||
const requiredErrors = useMemo(() => {
|
||||
if (!node || !nodeType) return [];
|
||||
return findRequiredErrors(node, nodeType, (p) => p.name);
|
||||
}, [node, nodeType]);
|
||||
|
||||
// Resolve full descriptions per missing param (for the banner tooltip).
|
||||
const requiredErrorTooltip = useMemo(() => {
|
||||
if (!requiredErrors.length || !nodeType) return '';
|
||||
const byName = new Map((nodeType.parameters ?? []).map((p) => [p.name, p]));
|
||||
return requiredErrors
|
||||
.map((e) => {
|
||||
const p = byName.get(e.paramName);
|
||||
const desc = p ? (getLabel(p.description, language) || '') : '';
|
||||
return desc ? `${e.paramName}: ${desc}` : e.paramName;
|
||||
})
|
||||
.join('\n');
|
||||
}, [requiredErrors, nodeType, language]);
|
||||
|
||||
if (!node || !nodeType) return null;
|
||||
|
||||
const isTrigger = node.type.startsWith('trigger.');
|
||||
const showNameField = onNodeUpdate && !isTrigger;
|
||||
const parameters = nodeType.parameters || [];
|
||||
const parameters = sortedParameters;
|
||||
|
||||
const inputPortDefs = nodeType.inputPorts ?? {};
|
||||
const outputPortDefs = nodeType.outputPorts ?? {};
|
||||
|
|
@ -111,14 +150,23 @@ export const NodeConfigPanel: React.FC<NodeConfigPanelProps> = ({ node,
|
|||
{getLabel(nodeType.description, language)}
|
||||
</p>
|
||||
)}
|
||||
{hasPortInfo && (
|
||||
<details className={styles.nodeConfigPorts ?? ''} style={{ margin: '0 0 0.75rem', fontSize: '0.75rem' }}>
|
||||
<summary style={{ cursor: 'pointer', color: 'var(--text-secondary, #666)', fontWeight: 600, padding: '0.25rem 0' }}>
|
||||
{t('Datenfluss (Eingabe / Ausgabe)')}
|
||||
{hasPortInfo && verboseSchema && (
|
||||
<details className={styles.nodeConfigPorts ?? ''} style={{ margin: '0 0 0.75rem', fontSize: '0.7rem' }}>
|
||||
<summary
|
||||
style={{
|
||||
cursor: 'pointer',
|
||||
color: 'var(--text-secondary)',
|
||||
fontWeight: 500,
|
||||
padding: '0.15rem 0',
|
||||
fontStyle: 'italic',
|
||||
}}
|
||||
title={t('Statische Schema-Referenz f\u00fcr diesen Node-Typ \u2014 keine Live-Daten')}
|
||||
>
|
||||
{t('Schema (Typ-Referenz, Sysadmin-Ansicht)')}
|
||||
</summary>
|
||||
{inputPortEntries.length > 0 && (
|
||||
<div style={{ marginTop: '0.4rem' }}>
|
||||
<div style={{ color: 'var(--text-secondary, #666)', fontWeight: 600, marginBottom: 2 }}>
|
||||
<div style={{ color: 'var(--text-secondary)', fontWeight: 600, marginBottom: 2 }}>
|
||||
{'\u2B07'} {t('Eingabe')}
|
||||
</div>
|
||||
{inputPortEntries.map(([idx, def]) => (
|
||||
|
|
@ -135,14 +183,14 @@ export const NodeConfigPanel: React.FC<NodeConfigPanelProps> = ({ node,
|
|||
)}
|
||||
{outputPortEntries.length > 0 && (
|
||||
<div style={{ marginTop: '0.4rem' }}>
|
||||
<div style={{ color: 'var(--text-secondary, #666)', fontWeight: 600, marginBottom: 2 }}>
|
||||
<div style={{ color: 'var(--text-secondary)', fontWeight: 600, marginBottom: 2 }}>
|
||||
{'\u2B06'} {t('Ausgabe')}
|
||||
</div>
|
||||
{outputPortEntries.map(([idx, def]) => (
|
||||
<_PortFieldList
|
||||
key={`out-${idx}`}
|
||||
portIndex={Number(idx)}
|
||||
schemaNames={def?.schema ? [def.schema] : []}
|
||||
schemaNames={_schemaNamesFromOutputPort(def)}
|
||||
catalog={portTypeCatalog}
|
||||
emptyLabel={t('keine Felder')}
|
||||
language={language}
|
||||
|
|
@ -152,26 +200,147 @@ export const NodeConfigPanel: React.FC<NodeConfigPanelProps> = ({ node,
|
|||
)}
|
||||
</details>
|
||||
)}
|
||||
{requiredErrors.length > 0 && (
|
||||
<div
|
||||
style={{
|
||||
marginBottom: 8,
|
||||
padding: '6px 10px',
|
||||
background: 'rgba(220,53,69,0.10)',
|
||||
borderLeft: '3px solid var(--danger-color, #dc3545)',
|
||||
borderRadius: 4,
|
||||
fontSize: 12,
|
||||
color: 'var(--danger-color, #dc3545)',
|
||||
overflowWrap: 'anywhere',
|
||||
wordBreak: 'break-word',
|
||||
}}
|
||||
title={requiredErrorTooltip || undefined}
|
||||
>
|
||||
{t('Pflicht-Felder ohne Quelle:')}{' '}
|
||||
<strong>{requiredErrors.map((e) => e.paramLabel).join(', ')}</strong>
|
||||
</div>
|
||||
)}
|
||||
{parameters.map((param: NodeTypeParameter) => {
|
||||
// Safety net: hidden params have no UI footprint at all — no row,
|
||||
// no required-mark, no type-badge. Their value is system-set.
|
||||
if (param.frontendType === 'hidden') return null;
|
||||
const useRequiredPicker = _shouldUseRequiredPicker(param);
|
||||
if (useRequiredPicker) {
|
||||
return (
|
||||
<div key={param.name} style={{ marginBottom: 8 }}>
|
||||
<RequiredAttributePicker
|
||||
label={getLabel(param.description, language) || param.name}
|
||||
expectedType={param.type}
|
||||
value={params[param.name] ?? param.default}
|
||||
onChange={(val) => updateParam(param.name, val)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
const frontendType = param.frontendType || 'text';
|
||||
const Renderer = FRONTEND_TYPE_RENDERERS[frontendType] ?? FRONTEND_TYPE_RENDERERS.text;
|
||||
return (
|
||||
<Renderer
|
||||
key={param.name}
|
||||
param={param}
|
||||
value={params[param.name] ?? param.default}
|
||||
onChange={(val: unknown) => updateParam(param.name, val)}
|
||||
allParams={params}
|
||||
instanceId={instanceId}
|
||||
request={request}
|
||||
nodeType={node.type}
|
||||
/>
|
||||
<div key={param.name} style={{ marginBottom: 4, minWidth: 0, maxWidth: '100%' }}>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 6,
|
||||
marginBottom: 2,
|
||||
flexWrap: 'wrap',
|
||||
minWidth: 0,
|
||||
}}
|
||||
>
|
||||
{param.required && (
|
||||
<span
|
||||
title={t('Pflichtfeld')}
|
||||
style={{ color: 'var(--danger-color, #dc3545)', fontWeight: 700, flexShrink: 0 }}
|
||||
>
|
||||
*
|
||||
</span>
|
||||
)}
|
||||
{verboseSchema && param.type && (
|
||||
<span
|
||||
title={t('Parameter-Typ')}
|
||||
style={{
|
||||
fontSize: 10,
|
||||
fontWeight: 600,
|
||||
color: 'var(--text-secondary)',
|
||||
background: 'var(--bg-secondary)',
|
||||
border: '1px solid var(--border-color)',
|
||||
borderRadius: 4,
|
||||
padding: '1px 6px',
|
||||
maxWidth: '100%',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
>
|
||||
{param.type}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<Renderer
|
||||
param={param}
|
||||
value={params[param.name] ?? param.default}
|
||||
onChange={(val: unknown) => updateParam(param.name, val)}
|
||||
allParams={params}
|
||||
instanceId={instanceId}
|
||||
request={request}
|
||||
nodeType={node.type}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/** Heuristic: required params with a Schicht-1 catalog type (non-primitive
|
||||
* ref/record/list) get the typed RequiredAttributePicker; primitive scalars
|
||||
* fall through to the legacy frontend-type renderer (text/number/select etc.)
|
||||
* unless they have no frontendType at all and a non-trivial type. */
|
||||
function _shouldUseRequiredPicker(param: NodeTypeParameter): boolean {
|
||||
if (!param.required) return false;
|
||||
if (!param.type) return false;
|
||||
// Hidden params never get a picker — they are system-set or rendered to
|
||||
// nothing on purpose. The render loop above also skips hidden rows entirely.
|
||||
if (param.frontendType === 'hidden') return false;
|
||||
// Always defer to specialized FE renderers when explicitly chosen.
|
||||
if (param.frontendType && _LEGACY_RENDERERS_THAT_HANDLE_BINDINGS.has(param.frontendType)) {
|
||||
return false;
|
||||
}
|
||||
// Catalog ref/record/list types are best handled by RequiredAttributePicker.
|
||||
if (/^(List\[|Dict\[)/.test(param.type)) return true;
|
||||
if (/^[A-Z]/.test(param.type)) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
const _LEGACY_RENDERERS_THAT_HANDLE_BINDINGS = new Set([
|
||||
'userConnection',
|
||||
'featureInstance',
|
||||
'sharepointFolder',
|
||||
'sharepointFile',
|
||||
'clickupList',
|
||||
'clickupTask',
|
||||
'dataRef',
|
||||
'caseList',
|
||||
'fieldBuilder',
|
||||
'keyValueRows',
|
||||
'cron',
|
||||
'condition',
|
||||
'mappingTable',
|
||||
'filterExpression',
|
||||
'attachmentBuilder',
|
||||
'json',
|
||||
]);
|
||||
|
||||
function _schemaNamesFromOutputPort(def: { schema?: string | GraphDefinedSchemaRef } | undefined): string[] {
|
||||
if (!def?.schema) return [];
|
||||
if (typeof def.schema === 'string') return [def.schema];
|
||||
if (typeof def.schema === 'object' && def.schema.kind === 'fromGraph') return ['FormPayload', 'FormPayload_dynamic'];
|
||||
return [];
|
||||
}
|
||||
|
||||
interface _PortFieldListProps {
|
||||
portIndex: number;
|
||||
schemaNames: string[];
|
||||
|
|
@ -184,7 +353,7 @@ const _PortFieldList: React.FC<_PortFieldListProps> = ({ portIndex, schemaNames,
|
|||
if (!schemaNames.length) return null;
|
||||
return (
|
||||
<div style={{ marginLeft: 4, marginBottom: 4 }}>
|
||||
<div style={{ color: '#888', fontSize: '0.7rem' }}>
|
||||
<div style={{ color: 'var(--text-secondary)', fontSize: '0.7rem' }}>
|
||||
{`#${portIndex} `}{schemaNames.join(' | ')}
|
||||
</div>
|
||||
{schemaNames.map((name) => {
|
||||
|
|
@ -192,14 +361,14 @@ const _PortFieldList: React.FC<_PortFieldListProps> = ({ portIndex, schemaNames,
|
|||
const fields = schema?.fields ?? [];
|
||||
if (name === 'Transit') {
|
||||
return (
|
||||
<div key={name} style={{ marginLeft: 8, color: '#999', fontStyle: 'italic', fontSize: '0.7rem' }}>
|
||||
<div key={name} style={{ marginLeft: 8, color: 'var(--text-tertiary)', fontStyle: 'italic', fontSize: '0.7rem' }}>
|
||||
{'\u00B7 Transit (durchgereichte Daten)'}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (!fields.length) {
|
||||
return (
|
||||
<div key={name} style={{ marginLeft: 8, color: '#bbb', fontSize: '0.7rem' }}>
|
||||
<div key={name} style={{ marginLeft: 8, color: 'var(--text-tertiary)', fontSize: '0.7rem' }}>
|
||||
{`\u00B7 ${emptyLabel}`}
|
||||
</div>
|
||||
);
|
||||
|
|
@ -207,12 +376,12 @@ const _PortFieldList: React.FC<_PortFieldListProps> = ({ portIndex, schemaNames,
|
|||
return (
|
||||
<ul key={name} style={{ margin: '2px 0 4px 16px', padding: 0, listStyle: 'none' }}>
|
||||
{fields.map((f) => (
|
||||
<li key={f.name} style={{ fontSize: '0.7rem', lineHeight: 1.4, color: '#555' }}>
|
||||
<span style={{ fontFamily: 'monospace', color: '#222' }}>{f.name}</span>
|
||||
<span style={{ color: '#999' }}>{`: ${f.type}`}</span>
|
||||
{!f.required && <span style={{ color: '#bbb' }}>{' (optional)'}</span>}
|
||||
<li key={f.name} style={{ fontSize: '0.7rem', lineHeight: 1.4, color: 'var(--text-secondary)' }}>
|
||||
<span style={{ fontFamily: 'monospace', color: 'var(--text-primary)' }}>{f.name}</span>
|
||||
<span style={{ color: 'var(--text-tertiary)' }}>{`: ${f.type}`}</span>
|
||||
{!f.required && <span style={{ color: 'var(--text-tertiary)' }}>{' (optional)'}</span>}
|
||||
{f.description && (
|
||||
<div style={{ color: '#888', marginLeft: 4 }}>
|
||||
<div style={{ color: 'var(--text-secondary)', marginLeft: 4 }}>
|
||||
{getLabel(f.description, language)}
|
||||
</div>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -2,45 +2,17 @@
|
|||
* Form node config - draggable fields, types, required toggle
|
||||
*/
|
||||
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import React from 'react';
|
||||
import { FaGripVertical, FaTimes } from 'react-icons/fa';
|
||||
import type { FormField, NodeConfigRendererProps } from '../shared/types';
|
||||
import { fetchConnections, type UserConnection } from '../../../../api/workflowApi';
|
||||
import { FORM_FIELD_TYPES, FORM_FIELD_TYPE_LABELS } from '../../../../utils/attributeTypeMapper';
|
||||
import styles from '../../editor/Automation2FlowEditor.module.css';
|
||||
|
||||
import { useLanguage } from '../../../../providers/language/LanguageContext';
|
||||
|
||||
export const FormNodeConfig: React.FC<NodeConfigRendererProps> = ({ params,
|
||||
updateParam,
|
||||
instanceId,
|
||||
request,
|
||||
}) => {
|
||||
export const FormNodeConfig: React.FC<NodeConfigRendererProps> = ({ params, updateParam }) => {
|
||||
const { t } = useLanguage();
|
||||
const fields = (params.fields as FormField[]) ?? [];
|
||||
const [connections, setConnections] = useState<UserConnection[]>([]);
|
||||
const [connectionsLoading, setConnectionsLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!instanceId || !request) {
|
||||
setConnections([]);
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
setConnectionsLoading(true);
|
||||
fetchConnections(request, instanceId)
|
||||
.then((rows) => {
|
||||
if (!cancelled) setConnections(rows.filter((c) => c.authority === 'clickup'));
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setConnections([]);
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setConnectionsLoading(false);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [instanceId, request]);
|
||||
|
||||
const moveField = (fromIndex: number, toIndex: number) => {
|
||||
if (fromIndex < 0 || toIndex < 0 || fromIndex >= fields.length || toIndex >= fields.length) return;
|
||||
|
|
@ -108,33 +80,17 @@ export const FormNodeConfig: React.FC<NodeConfigRendererProps> = ({ params,
|
|||
</div>
|
||||
<div className={styles.formFieldRowFooter}>
|
||||
<select
|
||||
value={f.type ?? 'string'}
|
||||
value={f.type ?? 'text'}
|
||||
onChange={(e) => {
|
||||
const next = [...fields];
|
||||
const fieldType = e.target.value;
|
||||
next[i] = {
|
||||
...next[i],
|
||||
type: fieldType,
|
||||
...(fieldType === 'clickup_tasks'
|
||||
? { clickupStatusOptions: undefined }
|
||||
: fieldType === 'clickup_status'
|
||||
? { clickupConnectionId: undefined, clickupListId: undefined }
|
||||
: {
|
||||
clickupConnectionId: undefined,
|
||||
clickupListId: undefined,
|
||||
clickupStatusOptions: undefined,
|
||||
}),
|
||||
};
|
||||
next[i] = { name: f.name, label: f.label, type: e.target.value as FormField['type'], required: f.required };
|
||||
updateParam('fields', next);
|
||||
}}
|
||||
style={{ width: 'auto', minWidth: 90 }}
|
||||
>
|
||||
<option value="string">{t('Text')}</option>
|
||||
<option value="number">{t('Zahl')}</option>
|
||||
<option value="date">{t('Datum')}</option>
|
||||
<option value="boolean">{t('Kontrollkästchen')}</option>
|
||||
<option value="clickup_tasks">{t('ClickUp-Aufgabe Referenz')}</option>
|
||||
<option value="clickup_status">{t('ClickUp-Status Liste')}</option>
|
||||
{FORM_FIELD_TYPES.map(ft => (
|
||||
<option key={ft} value={ft}>{t(FORM_FIELD_TYPE_LABELS[ft])}</option>
|
||||
))}
|
||||
</select>
|
||||
<label className={styles.formFieldRequiredLabel}>
|
||||
<input
|
||||
|
|
@ -157,72 +113,12 @@ export const FormNodeConfig: React.FC<NodeConfigRendererProps> = ({ params,
|
|||
<FaTimes />
|
||||
</button>
|
||||
</div>
|
||||
{f.type === 'clickup_status' ? (
|
||||
<div style={{ marginTop: 8, paddingLeft: 4, width: '100%', fontSize: '0.8rem', color: 'var(--text-secondary, #666)' }}>
|
||||
{Array.isArray(f.clickupStatusOptions) && f.clickupStatusOptions.length > 0 ? (
|
||||
<p style={{ margin: '0 0 6px' }}>
|
||||
{t(
|
||||
'Dropdown mit {count} Status aus der ClickUp-Liste (Wert = exakter Status-Name für die API).',
|
||||
{ count: String(f.clickupStatusOptions.length) }
|
||||
)}
|
||||
</p>
|
||||
) : (
|
||||
<p style={{ margin: '0 0 6px' }}>
|
||||
{t(
|
||||
'Keine Optionen — im ClickUp-Knoten „Aufgabe erstellen“ Liste wählen und „Formular mit Liste abgleichen“.'
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
{f.type === 'clickup_tasks' ? (
|
||||
<div style={{ marginTop: 8, paddingLeft: 4, width: '100%' }}>
|
||||
<label style={{ display: 'block', fontSize: '0.8rem', marginBottom: 4 }}>
|
||||
{t('ClickUp-Verbindung')}
|
||||
</label>
|
||||
<select
|
||||
value={f.clickupConnectionId ?? ''}
|
||||
onChange={(e) => {
|
||||
const next = [...fields];
|
||||
next[i] = { ...next[i], clickupConnectionId: e.target.value };
|
||||
updateParam('fields', next);
|
||||
}}
|
||||
disabled={connectionsLoading || !instanceId}
|
||||
style={{ width: '100%', marginBottom: 8 }}
|
||||
>
|
||||
<option value="">{connectionsLoading ? t('Lade…') : t('Verbindung wählen…')}</option>
|
||||
{connections.map((c) => (
|
||||
<option key={c.id} value={c.id}>
|
||||
{c.externalUsername ?? c.id}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<label style={{ display: 'block', fontSize: '0.8rem', marginBottom: 4 }}>
|
||||
{t('Listen-ID (verknüpfte Liste / Ziel-Liste)')}
|
||||
</label>
|
||||
<input
|
||||
placeholder={t('z.B. aus ClickUp-URL: list/123456789')}
|
||||
value={f.clickupListId ?? ''}
|
||||
onChange={(e) => {
|
||||
const next = [...fields];
|
||||
next[i] = { ...next[i], clickupListId: e.target.value };
|
||||
updateParam('fields', next);
|
||||
}}
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
<p style={{ fontSize: '0.75rem', color: 'var(--text-secondary, #666)', marginTop: 6 }}>
|
||||
{t('Liefert beim Ausfüllen denselben Wert wie ein ClickUp-Relationship-Feld:')}{' '}
|
||||
<code>{'{ add: [taskId], rem: [] }'}</code>{' '}
|
||||
{t('— im ClickUp-Node per Datenquelle auf das Formularfeld mappen.')}
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
))}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
updateParam('fields', [...fields, { name: '', type: 'string', label: '', required: false }])
|
||||
updateParam('fields', [...fields, { name: '', type: 'text', label: '', required: false }])
|
||||
}
|
||||
>
|
||||
+ {t('Feld')}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,168 @@
|
|||
/**
|
||||
* DataRefRenderer — Pick-not-Push attribute binding using the existing
|
||||
* hierarchical DataPicker.
|
||||
*
|
||||
* For required typed parameters (e.g. ``documentList: DocumentList``) where
|
||||
* the user must explicitly bind to an upstream node's typed output. Replaces
|
||||
* the legacy ``frontendType: "hidden"`` so the binding becomes visible and
|
||||
* editable directly in the node config panel.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { useLanguage } from '../../../../providers/language/LanguageContext';
|
||||
import { useAutomation2DataFlow } from '../../context/Automation2DataFlowContext';
|
||||
import { DataPicker } from '../shared/DataPicker';
|
||||
import { isRef, type DataRef, type SystemVarRef } from '../shared/dataRef';
|
||||
import type { FieldRendererProps } from './index';
|
||||
|
||||
export const DataRefRenderer: React.FC<FieldRendererProps> = ({ param, value, onChange }) => {
|
||||
const { t } = useLanguage();
|
||||
const dataFlow = useAutomation2DataFlow();
|
||||
const [pickerOpen, setPickerOpen] = React.useState(false);
|
||||
|
||||
const currentRef = isRef(value) ? (value as DataRef) : null;
|
||||
const isMissing = param.required && !currentRef;
|
||||
|
||||
const sourceIds = dataFlow?.getAvailableSourceIds() ?? [];
|
||||
const hasSources = sourceIds.some((id) => {
|
||||
const n = dataFlow?.nodes.find((x) => x.id === id);
|
||||
return n?.type !== 'trigger.manual';
|
||||
});
|
||||
|
||||
const currentNodeLabel = currentRef
|
||||
? dataFlow?.getNodeLabel(
|
||||
dataFlow.nodes.find((n) => n.id === currentRef.nodeId) ?? { id: currentRef.nodeId },
|
||||
) ?? currentRef.nodeId
|
||||
: null;
|
||||
|
||||
const onPick = (picked: DataRef | SystemVarRef) => {
|
||||
onChange(picked);
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ marginBottom: 8 }}>
|
||||
<label style={{ display: 'block', fontSize: 12, marginBottom: 2, fontWeight: 600 }}>
|
||||
{param.description || param.name}
|
||||
{param.required && <span style={{ color: '#d9534f', marginLeft: 4 }}>*</span>}
|
||||
{param.type && (
|
||||
<span
|
||||
style={{
|
||||
marginLeft: 6,
|
||||
fontFamily: 'monospace',
|
||||
fontWeight: 500,
|
||||
fontSize: 10,
|
||||
color: '#666',
|
||||
background: '#eef',
|
||||
padding: '0 4px',
|
||||
borderRadius: 3,
|
||||
}}
|
||||
title={t('Erwarteter Typ')}
|
||||
>
|
||||
{param.type}
|
||||
</span>
|
||||
)}
|
||||
</label>
|
||||
|
||||
{currentRef && (
|
||||
<div
|
||||
style={{
|
||||
padding: '4px 8px',
|
||||
background: '#eaf6e8',
|
||||
border: '1px solid #5cb85c',
|
||||
borderRadius: 4,
|
||||
fontSize: 12,
|
||||
marginBottom: 4,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 6,
|
||||
}}
|
||||
title={t('Aktive DataRef-Bindung')}
|
||||
>
|
||||
<span style={{ color: '#3c763d', fontWeight: 700 }}>{'\u2190'}</span>
|
||||
<span style={{ fontFamily: 'monospace', color: '#3c763d', flex: 1 }}>
|
||||
{currentNodeLabel}
|
||||
{currentRef.path.length > 0 && (
|
||||
<>
|
||||
<span style={{ color: '#999' }}>{' \u2192 '}</span>
|
||||
{currentRef.path.map((p) => String(p)).join('.')}
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
{currentRef.expectedType && (
|
||||
<span style={{ fontSize: 10, color: '#666', fontFamily: 'monospace' }}>
|
||||
{currentRef.expectedType}
|
||||
</span>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onChange(undefined)}
|
||||
title={t('Bindung entfernen')}
|
||||
style={{
|
||||
padding: '0 6px',
|
||||
border: '1px solid #5cb85c',
|
||||
borderRadius: 3,
|
||||
background: '#fff',
|
||||
color: '#3c763d',
|
||||
cursor: 'pointer',
|
||||
fontSize: 11,
|
||||
}}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isMissing && (
|
||||
<div
|
||||
style={{
|
||||
padding: '4px 8px',
|
||||
background: '#fdecea',
|
||||
border: '1px solid #d9534f',
|
||||
borderRadius: 4,
|
||||
fontSize: 12,
|
||||
color: '#a94442',
|
||||
marginBottom: 4,
|
||||
}}
|
||||
>
|
||||
{t('Pflicht-Bindung fehlt — Quelle aus Upstream-Node wählen.')}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setPickerOpen(true)}
|
||||
disabled={!hasSources}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '4px 8px',
|
||||
borderRadius: 4,
|
||||
border: `1px solid ${isMissing ? '#d9534f' : currentRef ? '#5cb85c' : '#1c5fb5'}`,
|
||||
background: hasSources ? '#fff' : '#f5f5f5',
|
||||
color: hasSources ? '#1c5fb5' : '#999',
|
||||
cursor: hasSources ? 'pointer' : 'not-allowed',
|
||||
fontSize: 12,
|
||||
textAlign: 'left',
|
||||
}}
|
||||
>
|
||||
{!hasSources
|
||||
? t('Keine vorherigen Nodes verfügbar')
|
||||
: currentRef
|
||||
? t('Bindung ändern …')
|
||||
: t('Quelle wählen …')}
|
||||
</button>
|
||||
|
||||
{dataFlow && (
|
||||
<DataPicker
|
||||
open={pickerOpen}
|
||||
onClose={() => setPickerOpen(false)}
|
||||
onPick={onPick}
|
||||
availableSourceIds={sourceIds}
|
||||
nodes={dataFlow.nodes}
|
||||
nodeOutputsPreview={dataFlow.nodeOutputsPreview}
|
||||
getNodeLabel={dataFlow.getNodeLabel}
|
||||
expectedParamType={param.type}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
@ -0,0 +1,158 @@
|
|||
/**
|
||||
* FeatureInstancePicker — renderer for frontendType="featureInstance".
|
||||
*
|
||||
* Modeled on ConnectionPicker. Loads mandate-scoped FeatureInstances filtered
|
||||
* by `frontendOptions.featureCode` (e.g. "trustee", "redmine") via
|
||||
* GET /api/workflows/{instanceId}/options/feature.instance?featureCode=<code>
|
||||
*
|
||||
* Behavior matches the rest of the editor:
|
||||
* - 0 results -> hint to create a feature instance for this mandate
|
||||
* - 1 result -> auto-pick (no manual click required)
|
||||
* - N results -> <select>
|
||||
*
|
||||
* The bound value is a plain `<id>` string so backend adapters can keep
|
||||
* using `featureInstanceId` lookups unchanged. Type stays
|
||||
* `FeatureInstanceRef[<code>]` on the parameter so DataPicker / RequiredAttributePicker
|
||||
* filter correctly.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { useLanguage } from '../../../../providers/language/LanguageContext';
|
||||
import type { FieldRendererProps } from './index';
|
||||
|
||||
type FeatureInstanceOption = { id: string; label: string };
|
||||
|
||||
export const FeatureInstancePicker: React.FC<FieldRendererProps> = ({
|
||||
param,
|
||||
value,
|
||||
onChange,
|
||||
instanceId,
|
||||
request,
|
||||
}) => {
|
||||
const { t } = useLanguage();
|
||||
const featureCode =
|
||||
(param.frontendOptions?.featureCode as string | undefined) || undefined;
|
||||
const [instances, setInstances] = React.useState<FeatureInstanceOption[]>([]);
|
||||
const [loading, setLoading] = React.useState(false);
|
||||
const [loadError, setLoadError] = React.useState<string | null>(null);
|
||||
const autoSingleRef = React.useRef(false);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!instanceId || !request || !featureCode) return;
|
||||
setLoading(true);
|
||||
setLoadError(null);
|
||||
request({
|
||||
url: `/api/workflows/${instanceId}/options/feature.instance?featureCode=${encodeURIComponent(featureCode)}`,
|
||||
method: 'get',
|
||||
})
|
||||
.then((res: unknown) => {
|
||||
const data = res as { options?: Array<{ value: string; label: string }> };
|
||||
setInstances((data?.options || []).map((o) => ({ id: o.value, label: o.label })));
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
console.error('FeatureInstancePicker: failed to load instances', err);
|
||||
setInstances([]);
|
||||
setLoadError(err instanceof Error ? err.message : String(err));
|
||||
})
|
||||
.finally(() => setLoading(false));
|
||||
}, [instanceId, request, featureCode]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (instances.length !== 1 || autoSingleRef.current) return;
|
||||
if (value !== '' && value !== undefined && value !== null) return;
|
||||
autoSingleRef.current = true;
|
||||
onChange(instances[0].id);
|
||||
}, [instances, value, onChange]);
|
||||
|
||||
const strVal = typeof value === 'string' ? value : '';
|
||||
const codeLabel = featureCode ?? t('Feature');
|
||||
|
||||
return (
|
||||
<div style={{ marginBottom: 8, minWidth: 0, maxWidth: '100%' }}>
|
||||
<label
|
||||
style={{
|
||||
display: 'block',
|
||||
fontSize: 12,
|
||||
marginBottom: 2,
|
||||
color: 'var(--text-primary)',
|
||||
overflowWrap: 'anywhere',
|
||||
wordBreak: 'break-word',
|
||||
}}
|
||||
>
|
||||
{param.description || param.name}
|
||||
</label>
|
||||
{loading && (
|
||||
<div style={{ fontSize: 11, color: 'var(--text-secondary)' }}>{t('Lade…')}</div>
|
||||
)}
|
||||
{!loading && instances.length === 0 && !loadError && (
|
||||
<div
|
||||
style={{
|
||||
fontSize: 11,
|
||||
color: 'var(--text-secondary)',
|
||||
marginBottom: 4,
|
||||
overflowWrap: 'anywhere',
|
||||
wordBreak: 'break-word',
|
||||
}}
|
||||
>
|
||||
{t('Keine {code}-Instanz im aktiven Mandanten — bitte in der Admin-Konsole anlegen.', { code: codeLabel })}
|
||||
</div>
|
||||
)}
|
||||
{!loading && instances.length === 1 && (
|
||||
<div
|
||||
style={{
|
||||
fontSize: 12,
|
||||
marginBottom: 4,
|
||||
color: 'var(--text-primary)',
|
||||
background: 'var(--bg-secondary)',
|
||||
border: '1px solid var(--border-color)',
|
||||
borderRadius: 4,
|
||||
padding: '4px 8px',
|
||||
maxWidth: '100%',
|
||||
overflowWrap: 'anywhere',
|
||||
wordBreak: 'break-word',
|
||||
lineHeight: 1.4,
|
||||
}}
|
||||
title={`${t('Einziger {code}-Mandant — automatisch gewählt.', { code: codeLabel })} — ${instances[0].label}`}
|
||||
>
|
||||
{instances[0].label}
|
||||
</div>
|
||||
)}
|
||||
{!loading && instances.length > 1 && (
|
||||
<select
|
||||
value={strVal}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
style={{
|
||||
width: '100%',
|
||||
maxWidth: '100%',
|
||||
boxSizing: 'border-box',
|
||||
padding: '4px 8px',
|
||||
borderRadius: 4,
|
||||
border: '1px solid var(--border-color)',
|
||||
background: 'var(--bg-primary)',
|
||||
color: 'var(--text-primary)',
|
||||
}}
|
||||
>
|
||||
<option value="">{t('{code}-Mandant wählen', { code: codeLabel })}</option>
|
||||
{instances.map((c) => (
|
||||
<option key={c.id} value={c.id}>{c.label}</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
{loadError && (
|
||||
<div
|
||||
style={{
|
||||
fontSize: 11,
|
||||
color: 'var(--danger-color, #c00)',
|
||||
marginTop: 2,
|
||||
overflowWrap: 'anywhere',
|
||||
wordBreak: 'break-word',
|
||||
}}
|
||||
>
|
||||
{t('Mandanten-Liste konnte nicht geladen werden')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default FeatureInstancePicker;
|
||||
|
|
@ -6,6 +6,7 @@
|
|||
import type { ComponentType } from 'react';
|
||||
import type { NodeTypeParameter } from '../../../../api/workflowApi';
|
||||
import type { ApiRequestFunction } from '../../../../api/workflowApi';
|
||||
import { FORM_FIELD_TYPES, FORM_FIELD_TYPE_LABELS } from '../../../../utils/attributeTypeMapper';
|
||||
|
||||
export interface FieldRendererProps {
|
||||
param: NodeTypeParameter;
|
||||
|
|
@ -26,6 +27,12 @@ export type FieldRendererComponent = ComponentType<FieldRendererProps>;
|
|||
import React from 'react';
|
||||
|
||||
import { useLanguage } from '../../../../providers/language/LanguageContext';
|
||||
import { useAutomation2DataFlow } from '../../context/Automation2DataFlowContext';
|
||||
import { toApiGraph } from '../shared/graphUtils';
|
||||
import { postUpstreamPaths } from '../../../../api/workflowApi';
|
||||
import type { CanvasNode } from '../../editor/FlowCanvas';
|
||||
import { DataRefRenderer } from './DataRefRenderer';
|
||||
import { FeatureInstancePicker } from './FeatureInstancePicker';
|
||||
|
||||
const TextInput: React.FC<FieldRendererProps> = ({ param, value, onChange }) => (
|
||||
<div style={{ marginBottom: 8 }}>
|
||||
|
|
@ -152,8 +159,11 @@ const HiddenInput: React.FC<FieldRendererProps> = () => null;
|
|||
|
||||
const ConnectionPicker: React.FC<FieldRendererProps> = ({ param, value, onChange, instanceId, request }) => {
|
||||
const { t } = useLanguage();
|
||||
const dataFlow = useAutomation2DataFlow();
|
||||
const [connections, setConnections] = React.useState<Array<{ id: string; label: string }>>([]);
|
||||
const [loadError, setLoadError] = React.useState<string | null>(null);
|
||||
const [upstreamBindOptions, setUpstreamBindOptions] = React.useState<Array<{ key: string; label: string; ref: unknown }>>([]);
|
||||
const autoSingleRef = React.useRef(false);
|
||||
const authority = (param.frontendOptions?.authority as string | undefined) || undefined;
|
||||
React.useEffect(() => {
|
||||
if (!instanceId || !request) return;
|
||||
|
|
@ -170,26 +180,100 @@ const ConnectionPicker: React.FC<FieldRendererProps> = ({ param, value, onChange
|
|||
setLoadError(err instanceof Error ? err.message : String(err));
|
||||
});
|
||||
}, [instanceId, request, authority]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!instanceId || !request || !dataFlow?.currentNodeId) {
|
||||
setUpstreamBindOptions([]);
|
||||
return;
|
||||
}
|
||||
const graph = toApiGraph(dataFlow.nodes as CanvasNode[], dataFlow.connections);
|
||||
postUpstreamPaths(request, instanceId, graph, dataFlow.currentNodeId)
|
||||
.then(({ paths }) => {
|
||||
const opts = paths
|
||||
.filter(
|
||||
(p) =>
|
||||
p.path.length > 0
|
||||
&& (String(p.path[p.path.length - 1]) === 'id' || p.path.join('.').includes('connection')),
|
||||
)
|
||||
.map((p, i) => ({
|
||||
key: `${p.producerNodeId}:${p.path.join('.')}:${i}`,
|
||||
label: `${p.producerLabel ?? p.producerNodeId} → ${p.label}`,
|
||||
ref: {
|
||||
type: 'ref',
|
||||
nodeId: p.producerNodeId,
|
||||
path: p.path,
|
||||
expectedType: p.type,
|
||||
},
|
||||
}));
|
||||
setUpstreamBindOptions(opts);
|
||||
})
|
||||
.catch(() => setUpstreamBindOptions([]));
|
||||
}, [instanceId, request, dataFlow?.currentNodeId, dataFlow?.nodes, dataFlow?.connections]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (connections.length !== 1 || autoSingleRef.current) return;
|
||||
if (value !== '' && value !== undefined && value !== null) return;
|
||||
autoSingleRef.current = true;
|
||||
onChange(connections[0].id);
|
||||
}, [connections, value, onChange]);
|
||||
|
||||
const strVal = typeof value === 'string' ? value : '';
|
||||
const isRef = typeof value === 'object' && value !== null && (value as { type?: string }).type === 'ref';
|
||||
const selectedUpstreamKey =
|
||||
isRef
|
||||
? upstreamBindOptions.find((o) => {
|
||||
const r = o.ref as { nodeId?: string; path?: unknown[] };
|
||||
const v = value as { nodeId?: string; path?: unknown[] };
|
||||
return r.nodeId === v.nodeId && JSON.stringify(r.path ?? []) === JSON.stringify(v.path ?? []);
|
||||
})?.key ?? ''
|
||||
: '';
|
||||
|
||||
return (
|
||||
<div style={{ marginBottom: 8 }}>
|
||||
<label style={{ display: 'block', fontSize: 12, marginBottom: 2 }}>{param.description || param.name}</label>
|
||||
<select
|
||||
value={typeof value === 'string' ? value : ''}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
style={{ width: '100%', padding: '4px 8px', borderRadius: 4, border: '1px solid #ccc' }}
|
||||
>
|
||||
<option value="">{t('Verbindung wählen')}</option>
|
||||
{connections.map((c) => (
|
||||
<option key={c.id} value={c.id}>{c.label}</option>
|
||||
))}
|
||||
</select>
|
||||
{!loadError && connections.length === 0 && (
|
||||
<div style={{ fontSize: 11, color: '#888', marginTop: 2 }}>
|
||||
{connections.length === 0 && !loadError && (
|
||||
<div style={{ fontSize: 11, color: '#888', marginBottom: 4 }}>
|
||||
{authority
|
||||
? t('Keine {authority}-Verbindung gefunden — Verbindung in den Account-Einstellungen anlegen.', { authority })
|
||||
: t('Keine Verbindung gefunden — Verbindung in den Account-Einstellungen anlegen.')}
|
||||
</div>
|
||||
)}
|
||||
{connections.length === 1 && (
|
||||
<div style={{ fontSize: 12, marginBottom: 4, color: '#444' }}>
|
||||
{connections[0].label}
|
||||
</div>
|
||||
)}
|
||||
{connections.length > 1 && (
|
||||
<select
|
||||
value={strVal}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
style={{ width: '100%', padding: '4px 8px', borderRadius: 4, border: '1px solid #ccc' }}
|
||||
>
|
||||
<option value="">{t('Verbindung wählen')}</option>
|
||||
{connections.map((c) => (
|
||||
<option key={c.id} value={c.id}>{c.label}</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
{upstreamBindOptions.length > 0 && (
|
||||
<div style={{ marginTop: 6 }}>
|
||||
<div style={{ fontSize: 11, color: '#666', marginBottom: 2 }}>{t('Oder aus vorherigem Node (DataRef)')}</div>
|
||||
<select
|
||||
value={selectedUpstreamKey}
|
||||
onChange={(e) => {
|
||||
const opt = upstreamBindOptions.find((o) => o.key === e.target.value);
|
||||
if (opt) onChange(opt.ref);
|
||||
else if (!e.target.value) onChange('');
|
||||
}}
|
||||
style={{ width: '100%', padding: '4px 8px', borderRadius: 4, border: '1px solid #ccc' }}
|
||||
>
|
||||
<option value="">{t('—')}</option>
|
||||
{upstreamBindOptions.map((o) => (
|
||||
<option key={o.key} value={o.key}>{o.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
{loadError && (
|
||||
<div style={{ fontSize: 11, color: '#c00', marginTop: 2 }}>{t('Verbindungen konnten nicht geladen werden')}</div>
|
||||
)}
|
||||
|
|
@ -464,18 +548,69 @@ const FieldBuilderEditor: React.FC<FieldRendererProps> = ({ param, value, onChan
|
|||
<div key={i} style={{ display: 'flex', gap: 4, marginBottom: 4, alignItems: 'center' }}>
|
||||
<input type="text" placeholder={t('Name')} value={String(f.name ?? '')} onChange={(e) => updateField(i, 'name', e.target.value)} style={{ flex: 1, padding: '2px 4px', borderRadius: 4, border: '1px solid #ccc' }} />
|
||||
<select value={String(f.type ?? 'text')} onChange={(e) => updateField(i, 'type', e.target.value)} style={{ flex: 1, padding: '2px 4px', borderRadius: 4, border: '1px solid #ccc' }}>
|
||||
<option value="text">{t('Text')}</option>
|
||||
<option value="number">{t('Zahl')}</option>
|
||||
<option value="date">{t('Datum')}</option>
|
||||
<option value="checkbox">{t('Kontrollkästchen')}</option>
|
||||
<option value="select">{t('Auswahl')}</option>
|
||||
<option value="textarea">{t('Mehrzeilig')}</option>
|
||||
{FORM_FIELD_TYPES.map(ft => (
|
||||
<option key={ft} value={ft}>{t(FORM_FIELD_TYPE_LABELS[ft])}</option>
|
||||
))}
|
||||
<option value="group">{t('Gruppe')}</option>
|
||||
</select>
|
||||
<input type="text" placeholder={t('Bezeichnung')} value={String(f.label ?? '')} onChange={(e) => updateField(i, 'label', e.target.value)} style={{ flex: 1, padding: '2px 4px', borderRadius: 4, border: '1px solid #ccc' }} />
|
||||
<label style={{ fontSize: 11, display: 'flex', alignItems: 'center', gap: 2 }}>
|
||||
<input type="checkbox" checked={Boolean(f.required)} onChange={(e) => updateField(i, 'required', e.target.checked)} /> {t('Pflicht')}
|
||||
</label>
|
||||
<button onClick={() => removeField(i)} style={{ padding: '2px 8px', borderRadius: 4, border: '1px solid #ccc', cursor: 'pointer' }}>×</button>
|
||||
{String(f.type) === 'group' && (
|
||||
<div style={{ width: '100%', marginTop: 6, marginLeft: 8, borderLeft: '2px solid #ddd', paddingLeft: 8 }}>
|
||||
<div style={{ fontSize: 11, color: '#666', marginBottom: 4 }}>{t('Unterfelder')}</div>
|
||||
{(Array.isArray(f.fields) ? f.fields : []).map((sub: Record<string, unknown>, j: number) => (
|
||||
<div key={j} style={{ display: 'flex', gap: 4, marginBottom: 4, flexWrap: 'wrap' }}>
|
||||
<input
|
||||
type="text"
|
||||
placeholder={t('Name')}
|
||||
value={String(sub.name ?? '')}
|
||||
onChange={(e) => {
|
||||
const nextFields = [...(Array.isArray(f.fields) ? f.fields : [])];
|
||||
nextFields[j] = { ...sub, name: e.target.value };
|
||||
updateField(i, 'fields', nextFields);
|
||||
}}
|
||||
style={{ flex: 1, minWidth: 80, padding: '2px 4px', borderRadius: 4, border: '1px solid #ccc' }}
|
||||
/>
|
||||
<select
|
||||
value={String(sub.type ?? 'text')}
|
||||
onChange={(e) => {
|
||||
const nextFields = [...(Array.isArray(f.fields) ? f.fields : [])];
|
||||
nextFields[j] = { ...sub, type: e.target.value };
|
||||
updateField(i, 'fields', nextFields);
|
||||
}}
|
||||
style={{ flex: 1, minWidth: 80, padding: '2px 4px', borderRadius: 4, border: '1px solid #ccc' }}
|
||||
>
|
||||
{FORM_FIELD_TYPES.map(ft => (
|
||||
<option key={ft} value={ft}>{t(FORM_FIELD_TYPE_LABELS[ft])}</option>
|
||||
))}
|
||||
</select>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
const nextFields = (Array.isArray(f.fields) ? f.fields : []).filter((_: unknown, k: number) => k !== j);
|
||||
updateField(i, 'fields', nextFields);
|
||||
}}
|
||||
style={{ padding: '2px 8px', borderRadius: 4, border: '1px solid #ccc', cursor: 'pointer' }}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
const nextFields = [...(Array.isArray(f.fields) ? f.fields : []), { name: '', type: 'text', label: '', required: false }];
|
||||
updateField(i, 'fields', nextFields);
|
||||
}}
|
||||
style={{ padding: '2px 8px', borderRadius: 4, border: '1px solid #ccc', cursor: 'pointer', fontSize: 11 }}
|
||||
>
|
||||
{t('Unterfeld hinzufügen')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
<button onClick={addField} style={{ padding: '2px 8px', borderRadius: 4, border: '1px solid #ccc', cursor: 'pointer', fontSize: 12 }}>{t('Feld hinzufügen')}</button>
|
||||
|
|
@ -618,7 +753,9 @@ export const FRONTEND_TYPE_RENDERERS: Record<string, FieldRendererComponent> = {
|
|||
json: JsonEditor,
|
||||
file: TextInput,
|
||||
hidden: HiddenInput,
|
||||
dataRef: DataRefRenderer,
|
||||
userConnection: ConnectionPicker,
|
||||
featureInstance: FeatureInstancePicker,
|
||||
sharepointFolder: SharepointPathPicker,
|
||||
sharepointFile: SharepointPathPicker,
|
||||
clickupList: FolderPicker,
|
||||
|
|
@ -630,6 +767,7 @@ export const FRONTEND_TYPE_RENDERERS: Record<string, FieldRendererComponent> = {
|
|||
condition: ConditionBuilder,
|
||||
mappingTable: MappingTableEditor,
|
||||
filterExpression: FilterExpressionEditor,
|
||||
attachmentBuilder: JsonEditor,
|
||||
};
|
||||
|
||||
export default FRONTEND_TYPE_RENDERERS;
|
||||
|
|
|
|||
194
src/components/FlowEditor/nodes/shared/DataPicker.test.tsx
Normal file
194
src/components/FlowEditor/nodes/shared/DataPicker.test.tsx
Normal file
|
|
@ -0,0 +1,194 @@
|
|||
// Copyright (c) 2025 Patrick Motsch
|
||||
// All rights reserved.
|
||||
//
|
||||
// Plan #2 — Track A1.2 / A1.3
|
||||
// T7: DataPicker strict-type filtering (only compatible candidates rendered).
|
||||
// T8: DataPicker generic object drill-down via wildcard '*' segment when the
|
||||
// schema declares List[X] of a known X.
|
||||
|
||||
import React from 'react';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import type { CanvasConnection, CanvasNode } from '../../editor/FlowCanvas';
|
||||
import type { NodeType, PortField, PortSchema } from '../../../../api/workflowApi';
|
||||
import type { DataRef, SystemVarRef } from './dataRef';
|
||||
|
||||
vi.mock('../../../../providers/language/LanguageContext', () => ({
|
||||
useLanguage: () => ({ t: (s: string) => s }),
|
||||
}));
|
||||
|
||||
let _ctxValue: unknown = null;
|
||||
vi.mock('../../context/Automation2DataFlowContext', () => ({
|
||||
useAutomation2DataFlow: () => _ctxValue,
|
||||
}));
|
||||
|
||||
import { DataPicker } from './DataPicker';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Fixtures
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function _field(name: string, type: string): PortField {
|
||||
return { name, type, description: '', required: false };
|
||||
}
|
||||
|
||||
const _docListSchema: PortSchema = {
|
||||
name: 'DocumentList',
|
||||
fields: [
|
||||
_field('documents', 'List[UdmDocument]'),
|
||||
_field('count', 'int'),
|
||||
_field('meta', 'str'),
|
||||
],
|
||||
};
|
||||
const _udmDocumentSchema: PortSchema = {
|
||||
name: 'UdmDocument',
|
||||
fields: [
|
||||
_field('name', 'str'),
|
||||
_field('mimeType', 'str'),
|
||||
_field('sizeBytes', 'int'),
|
||||
],
|
||||
};
|
||||
|
||||
const _portCatalog: Record<string, PortSchema> = {
|
||||
DocumentList: _docListSchema,
|
||||
UdmDocument: _udmDocumentSchema,
|
||||
};
|
||||
|
||||
function _setContext(opts: {
|
||||
consumerNodeId: string;
|
||||
nodes: CanvasNode[];
|
||||
connections: CanvasConnection[];
|
||||
nodeTypes: NodeType[];
|
||||
}) {
|
||||
_ctxValue = {
|
||||
currentNodeId: opts.consumerNodeId,
|
||||
nodes: opts.nodes,
|
||||
connections: opts.connections,
|
||||
nodeTypes: opts.nodeTypes,
|
||||
portTypeCatalog: _portCatalog,
|
||||
nodeOutputsPreview: {},
|
||||
systemVariables: {},
|
||||
language: 'de',
|
||||
getNodeLabel: (n: { id: string; title?: string }) => n.title ?? n.id,
|
||||
getAvailableSourceIds: () => opts.nodes.filter((n) => n.id !== opts.consumerNodeId).map((n) => n.id),
|
||||
parseGraphDefinedSchema: () => null,
|
||||
};
|
||||
}
|
||||
|
||||
function _node(id: string, type: string): CanvasNode {
|
||||
return { id, type, title: id, x: 0, y: 0, inputs: 1, outputs: 1, parameters: {} };
|
||||
}
|
||||
function _conn(id: string, src: string, tgt: string): CanvasConnection {
|
||||
return { id, sourceId: src, sourceHandle: 0, targetId: tgt, targetHandle: 0 };
|
||||
}
|
||||
function _nodeType(id: string, outputSchema: string): NodeType {
|
||||
return {
|
||||
id,
|
||||
label: id,
|
||||
description: id,
|
||||
category: 'test',
|
||||
parameters: [],
|
||||
inputs: 1,
|
||||
outputs: 1,
|
||||
outputPorts: [{ schema: outputSchema }],
|
||||
} as unknown as NodeType;
|
||||
}
|
||||
|
||||
function _renderPicker(props?: { expectedParamType?: string; onPick?: (r: DataRef | SystemVarRef) => void }) {
|
||||
const upstream = _node('up', 'sharepoint.readDocs');
|
||||
const consumer = _node('cons', 'ai.summarize');
|
||||
_setContext({
|
||||
consumerNodeId: 'cons',
|
||||
nodes: [upstream, consumer],
|
||||
connections: [_conn('c1', 'up', 'cons')],
|
||||
nodeTypes: [_nodeType('sharepoint.readDocs', 'DocumentList'), _nodeType('ai.summarize', 'AiResult')],
|
||||
});
|
||||
return render(
|
||||
<DataPicker
|
||||
open
|
||||
onClose={() => {}}
|
||||
onPick={props?.onPick ?? (() => {})}
|
||||
availableSourceIds={['up']}
|
||||
nodes={[upstream]}
|
||||
nodeOutputsPreview={{}}
|
||||
getNodeLabel={(n) => n.title ?? n.id}
|
||||
expectedParamType={props?.expectedParamType}
|
||||
/>,
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// T8: Wildcard drill-down
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('DataPicker — generic-object drill-down (T8)', () => {
|
||||
it('renders the wildcard "documents → * → name" path when drilling into List[UdmDocument]', async () => {
|
||||
_renderPicker();
|
||||
await userEvent.click(screen.getByText(/^up$/));
|
||||
expect(screen.getByText(/documents → \* → name/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/documents → \* → mimeType/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('lists the wholeOutput, top-level fields, and drilled fields together', async () => {
|
||||
_renderPicker();
|
||||
await userEvent.click(screen.getByText(/^up$/));
|
||||
expect(screen.getByText('documents')).toBeInTheDocument();
|
||||
expect(screen.getByText('count')).toBeInTheDocument();
|
||||
expect(screen.getByText('meta')).toBeInTheDocument();
|
||||
// Multiple drilled candidates exist (name, mimeType, sizeBytes, _success, _error).
|
||||
expect(screen.getAllByText(/documents → \*/).length).toBeGreaterThanOrEqual(2);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// T7: Strict type filter
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('DataPicker — strict type filtering (T7)', () => {
|
||||
it('hides hard-mismatch fields when expectedParamType is set + strict toggle is on (default)', async () => {
|
||||
_renderPicker({ expectedParamType: 'str' });
|
||||
expect(screen.getByLabelText(/Nur kompatible/i)).toBeChecked();
|
||||
await userEvent.click(screen.getByText(/^up$/));
|
||||
// documents (List[UdmDocument]) is a hard mismatch → must be hidden.
|
||||
expect(screen.queryByText('documents')).not.toBeInTheDocument();
|
||||
// meta (str) is exact match → kept.
|
||||
expect(screen.getByText('meta')).toBeInTheDocument();
|
||||
// count (int) is "coerce" against str → kept (coerce is allowed in strict mode).
|
||||
expect(screen.getByText('count')).toBeInTheDocument();
|
||||
// Drilled wildcard candidates of type str (name, mimeType) remain.
|
||||
expect(screen.getByText(/documents → \* → name/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows all fields after the user disables the strict toggle', async () => {
|
||||
_renderPicker({ expectedParamType: 'str' });
|
||||
await userEvent.click(screen.getByLabelText(/Nur kompatible/i));
|
||||
await userEvent.click(screen.getByText(/^up$/));
|
||||
expect(screen.getByText('documents')).toBeInTheDocument();
|
||||
expect(screen.getByText('count')).toBeInTheDocument();
|
||||
expect(screen.getByText('meta')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows the iterieren-button on List[X] candidates that match expectedParamType=X (T6)', async () => {
|
||||
_renderPicker({ expectedParamType: 'UdmDocument' });
|
||||
await userEvent.click(screen.getByText(/^up$/));
|
||||
// documents (List[UdmDocument]) is the only candidate with expectedParamType=UdmDocument
|
||||
expect(screen.getByText('documents')).toBeInTheDocument();
|
||||
expect(screen.getByText('iterieren')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('emits a wildcard ref when the user clicks "iterieren"', async () => {
|
||||
const onPick = vi.fn();
|
||||
_renderPicker({ expectedParamType: 'UdmDocument', onPick });
|
||||
await userEvent.click(screen.getByText(/^up$/));
|
||||
await userEvent.click(screen.getByText('iterieren'));
|
||||
expect(onPick).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
type: 'ref',
|
||||
nodeId: 'up',
|
||||
path: ['documents', '*'],
|
||||
expectedType: 'UdmDocument',
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
@ -5,10 +5,12 @@
|
|||
* Includes a System Variables section.
|
||||
*/
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import { createRef, createSystemVar, type DataRef, type SystemVarRef } from './dataRef';
|
||||
import React, { useMemo, useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { createRef, createSystemVar, type DataRef, type SystemVarRef, isCompatible } from './dataRef';
|
||||
import { useAutomation2DataFlow } from '../../context/Automation2DataFlowContext';
|
||||
import type { NodeType, PortSchema } from '../../../../api/workflowApi';
|
||||
import type { GraphDefinedSchemaRef, NodeType, PortSchema } from '../../../../api/workflowApi';
|
||||
import { findLoopAncestorIds } from './scopeHelpers';
|
||||
import styles from '../../editor/Automation2FlowEditor.module.css';
|
||||
|
||||
import { useLanguage } from '../../../../providers/language/LanguageContext';
|
||||
|
|
@ -18,33 +20,103 @@ interface DataPickerProps {
|
|||
onClose: () => void;
|
||||
onPick: (ref: DataRef | SystemVarRef) => void;
|
||||
availableSourceIds: string[];
|
||||
nodes: Array<{ id: string; title?: string; type?: string }>;
|
||||
nodes: Array<{ id: string; title?: string; type?: string; parameters?: Record<string, unknown> }>;
|
||||
nodeOutputsPreview: Record<string, unknown>;
|
||||
getNodeLabel: (node: { id: string; title?: string }) => string;
|
||||
/** When set, the picker can hide incompatible candidates (strict toggle) and
|
||||
* surfaces "Iterieren als Loop" affordances for List[X]→X candidates. */
|
||||
expectedParamType?: string;
|
||||
}
|
||||
|
||||
interface PickablePath {
|
||||
path: (string | number)[];
|
||||
label: string;
|
||||
type?: string;
|
||||
/** True iff this path produces `List[X]` and the consumer expects `X` —
|
||||
* picking with iterate=true appends the wildcard segment. */
|
||||
iterable?: boolean;
|
||||
}
|
||||
|
||||
const _LIST_INNER_RE = /^List\[(.+)\]$/;
|
||||
|
||||
function _buildPathsFromSchema(
|
||||
schema: PortSchema | undefined,
|
||||
catalog: Record<string, PortSchema>,
|
||||
basePath: (string | number)[] = [],
|
||||
depth = 0,
|
||||
): PickablePath[] {
|
||||
if (!schema || !schema.fields) return [];
|
||||
if (!schema || !schema.fields || depth > 8) return [];
|
||||
const result: PickablePath[] = [];
|
||||
for (const field of schema.fields) {
|
||||
const fieldPath = [...basePath, field.name];
|
||||
const label = fieldPath.map(String).join(' → ');
|
||||
result.push({ path: fieldPath, label, type: field.type });
|
||||
const m = typeof field.type === 'string' ? field.type.match(_LIST_INNER_RE) : null;
|
||||
const inner = m?.[1]?.trim();
|
||||
if (inner && catalog[inner]) {
|
||||
// Generic List drill-down: use '*' wildcard so the engine maps each item.
|
||||
result.push(..._buildPathsFromSchema(catalog[inner], catalog, [...fieldPath, '*'], depth + 1));
|
||||
}
|
||||
}
|
||||
result.push({ path: [...basePath, '_success'], label: [...basePath, '_success'].map(String).join(' → '), type: 'bool' });
|
||||
result.push({ path: [...basePath, '_error'], label: [...basePath, '_error'].map(String).join(' → '), type: 'str' });
|
||||
return result;
|
||||
}
|
||||
|
||||
/** Annotate each candidate with `iterable=true` if it is `List[X]` and the
|
||||
* consumer expects `X`. Used to render a "Iterieren als Loop"-Vorschlag. */
|
||||
function _markIterableCandidates(paths: PickablePath[], expectedParamType?: string): PickablePath[] {
|
||||
if (!expectedParamType) return paths;
|
||||
return paths.map((p) => {
|
||||
if (!p.type) return p;
|
||||
const m = p.type.match(_LIST_INNER_RE);
|
||||
if (m && m[1].trim() === expectedParamType) return { ...p, iterable: true };
|
||||
return p;
|
||||
});
|
||||
}
|
||||
|
||||
function _deriveFormPortSchemaFromParams(
|
||||
node: { parameters?: Record<string, unknown> },
|
||||
paramKey: string,
|
||||
): PortSchema | undefined {
|
||||
const raw = node.parameters?.[paramKey];
|
||||
if (!Array.isArray(raw)) return undefined;
|
||||
const fields: Array<{ name: string; type: string; description: string | Record<string, string>; required: boolean }> = [];
|
||||
for (const item of raw) {
|
||||
if (typeof item !== 'object' || item === null) continue;
|
||||
const rec = item as Record<string, unknown>;
|
||||
if (typeof rec.name !== 'string') continue;
|
||||
const lab = rec.label;
|
||||
let description: string | Record<string, string> = rec.name;
|
||||
if (typeof lab === 'string') description = lab;
|
||||
else if (lab && typeof lab === 'object') description = lab as Record<string, string>;
|
||||
const ftype = typeof rec.type === 'string' ? rec.type : 'str';
|
||||
if (ftype === 'group' && Array.isArray(rec.fields)) {
|
||||
for (const sub of rec.fields as Record<string, unknown>[]) {
|
||||
if (!sub || typeof sub.name !== 'string') continue;
|
||||
const sl = sub.label;
|
||||
let sdesc: string | Record<string, string> = `${rec.name}.${sub.name}`;
|
||||
if (typeof sl === 'string') sdesc = sl;
|
||||
else if (sl && typeof sl === 'object') sdesc = sl as Record<string, string>;
|
||||
fields.push({
|
||||
name: `${rec.name}.${sub.name}`,
|
||||
type: typeof sub.type === 'string' ? sub.type : 'str',
|
||||
description: sdesc,
|
||||
required: Boolean(sub.required),
|
||||
});
|
||||
}
|
||||
continue;
|
||||
}
|
||||
fields.push({
|
||||
name: rec.name,
|
||||
type: ftype,
|
||||
description,
|
||||
required: Boolean(rec.required),
|
||||
});
|
||||
}
|
||||
return fields.length ? { name: 'FormPayload_dynamic', fields } : undefined;
|
||||
}
|
||||
|
||||
function _buildPathsFromPreview(
|
||||
obj: unknown,
|
||||
basePath: (string | number)[] = [],
|
||||
|
|
@ -74,7 +146,7 @@ function _buildPathsFromPreview(
|
|||
|
||||
function _resolveSchemaForNode(
|
||||
nodeId: string,
|
||||
nodes: Array<{ id: string; type?: string }>,
|
||||
nodes: Array<{ id: string; type?: string; parameters?: Record<string, unknown> }>,
|
||||
nodeTypes: NodeType[],
|
||||
connections: Array<{ source: string; target: string; sourceOutput?: number }>,
|
||||
catalog: Record<string, PortSchema>,
|
||||
|
|
@ -88,11 +160,23 @@ function _resolveSchemaForNode(
|
|||
const typeDef = nodeTypes.find((nt) => nt.id === node.type);
|
||||
if (!typeDef?.outputPorts) return undefined;
|
||||
|
||||
const port0 = typeDef.outputPorts[0];
|
||||
const port0 = typeDef.outputPorts[0] as {
|
||||
schema?: string | GraphDefinedSchemaRef;
|
||||
dynamic?: boolean;
|
||||
deriveFrom?: string;
|
||||
};
|
||||
if (!port0) return undefined;
|
||||
|
||||
if (port0.schema !== 'Transit') {
|
||||
return catalog[port0.schema];
|
||||
const schemaSpec = port0.schema;
|
||||
if (typeof schemaSpec === 'object' && schemaSpec !== null && schemaSpec.kind === 'fromGraph') {
|
||||
const paramKey = schemaSpec.parameter ?? 'fields';
|
||||
return _deriveFormPortSchemaFromParams(node, paramKey);
|
||||
}
|
||||
if (port0.dynamic && port0.deriveFrom) {
|
||||
return _deriveFormPortSchemaFromParams(node, port0.deriveFrom);
|
||||
}
|
||||
if (typeof schemaSpec === 'string' && schemaSpec !== 'Transit') {
|
||||
return catalog[schemaSpec];
|
||||
}
|
||||
|
||||
// Transit: follow the incoming connection to find the real producer
|
||||
|
|
@ -108,23 +192,42 @@ export const DataPicker: React.FC<DataPickerProps> = ({ open,
|
|||
nodes,
|
||||
nodeOutputsPreview,
|
||||
getNodeLabel,
|
||||
expectedParamType,
|
||||
}) => {
|
||||
const { t } = useLanguage();
|
||||
const [expandedNodes, setExpandedNodes] = useState<Set<string>>(new Set());
|
||||
const [showSystem, setShowSystem] = useState(false);
|
||||
// Default: when the consumer declares an expected type, show only compatible
|
||||
// candidates ("strict" mode). User can override per-session via the toggle.
|
||||
const [strictFilter, setStrictFilter] = useState<boolean>(Boolean(expectedParamType));
|
||||
const ctx = useAutomation2DataFlow();
|
||||
|
||||
// NOTE: All hooks must be called unconditionally on every render to satisfy
|
||||
// the Rules of Hooks. The `if (!open) return null;` early-return therefore
|
||||
// has to live BELOW every hook in this component. Adding a useMemo (or any
|
||||
// other hook) below it would change the hook count when the picker toggles
|
||||
// open/closed and crash the whole tree (white screen).
|
||||
const connectionsRaw = ctx?.connections ?? [];
|
||||
const connections = useMemo(
|
||||
() =>
|
||||
connectionsRaw.map((c) => ({
|
||||
source: c.sourceId,
|
||||
target: c.targetId,
|
||||
sourceOutput: c.sourceHandle,
|
||||
})),
|
||||
[connectionsRaw],
|
||||
);
|
||||
const loopAncestorIds = useMemo(() => {
|
||||
const cid = ctx?.currentNodeId;
|
||||
if (!cid) return [] as string[];
|
||||
return findLoopAncestorIds(nodes, connections, cid);
|
||||
}, [ctx?.currentNodeId, nodes, connections]);
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
const catalog = ctx?.portTypeCatalog ?? {};
|
||||
const systemVars = ctx?.systemVariables ?? {};
|
||||
const nodeTypes = ctx?.nodeTypes ?? [];
|
||||
const connectionsRaw = ctx?.connections ?? [];
|
||||
const connections = connectionsRaw.map((c) => ({
|
||||
source: c.sourceId,
|
||||
target: c.targetId,
|
||||
sourceOutput: c.sourceHandle,
|
||||
}));
|
||||
|
||||
const toggleExpand = (nodeId: string) => {
|
||||
setExpandedNodes((prev) => {
|
||||
|
|
@ -135,8 +238,15 @@ export const DataPicker: React.FC<DataPickerProps> = ({ open,
|
|||
});
|
||||
};
|
||||
|
||||
const handlePick = (nodeId: string, path: (string | number)[]) => {
|
||||
onPick(createRef(nodeId, path));
|
||||
const handlePick = (nodeId: string, path: (string | number)[], expectedType?: string) => {
|
||||
onPick(createRef(nodeId, path, expectedType));
|
||||
onClose();
|
||||
};
|
||||
|
||||
/** Loop-Vorschlag: for List[X]→X candidates, append the '*' wildcard so the
|
||||
* engine maps the consumer over each element (executionEngine wildcard). */
|
||||
const handlePickIterate = (nodeId: string, path: (string | number)[], expectedType?: string) => {
|
||||
onPick(createRef(nodeId, [...path, '*'], expectedType));
|
||||
onClose();
|
||||
};
|
||||
|
||||
|
|
@ -145,17 +255,98 @@ export const DataPicker: React.FC<DataPickerProps> = ({ open,
|
|||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={styles.dataPickerOverlay} onClick={onClose}>
|
||||
<div className={styles.dataPickerModal} onClick={(e) => e.stopPropagation()}>
|
||||
const _dialog = (
|
||||
<div
|
||||
className={styles.dataPickerOverlay}
|
||||
onClick={onClose}
|
||||
onKeyDown={(e) => e.key === 'Escape' && onClose()}
|
||||
role="presentation"
|
||||
>
|
||||
<div
|
||||
className={styles.dataPickerModal}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="automation2DataPickerTitle"
|
||||
>
|
||||
<div className={styles.dataPickerHeader}>
|
||||
<h4 className={styles.dataPickerTitle}>{t('Datenquelle wählen')}</h4>
|
||||
<button type="button" className={styles.dataPickerClose} onClick={onClose} aria-label={t('Schließen')}>
|
||||
×
|
||||
</button>
|
||||
<h4 className={styles.dataPickerTitle} id="automation2DataPickerTitle">
|
||||
{t('Datenquelle wählen')}
|
||||
{expectedParamType && (
|
||||
<span
|
||||
className={styles.dataPickerTypeBadge}
|
||||
title={t('Erwarteter Typ')}
|
||||
>
|
||||
{expectedParamType}
|
||||
</span>
|
||||
)}
|
||||
</h4>
|
||||
<div className={styles.dataPickerHeaderControls}>
|
||||
{expectedParamType && (
|
||||
<label className={styles.dataPickerStrictLabel}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={strictFilter}
|
||||
onChange={(e) => setStrictFilter(e.target.checked)}
|
||||
/>
|
||||
{t('Nur kompatible')}
|
||||
</label>
|
||||
)}
|
||||
<button type="button" className={styles.dataPickerClose} onClick={onClose} aria-label={t('Schließen')}>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.dataPickerBody}>
|
||||
{/* System Variables Section */}
|
||||
{loopAncestorIds.length > 0 && (
|
||||
<div className={styles.dataPickerNodeSection}>
|
||||
<div className={styles.dataPickerNodeHeader} style={{ cursor: 'default' }}>
|
||||
<span className={styles.dataPickerNodeLabel}>{t('Schleife (lexikalisch)')}</span>
|
||||
</div>
|
||||
<div className={styles.dataPickerTree}>
|
||||
{loopAncestorIds.map((loopId) => {
|
||||
const loopNode = nodes.find((n) => n.id === loopId);
|
||||
const loopLabel = loopNode ? getNodeLabel(loopNode as { id: string; title?: string }) : loopId;
|
||||
const loopSchema = catalog.LoopItem;
|
||||
const loopPaths = loopSchema
|
||||
? _buildPathsFromSchema(loopSchema, catalog, [], 0).filter((p) => !String(p.path[p.path.length - 1]).startsWith('_'))
|
||||
: [
|
||||
{ path: ['currentItem'], label: 'currentItem', type: 'Any' },
|
||||
{ path: ['currentIndex'], label: 'currentIndex', type: 'int' },
|
||||
{ path: ['count'], label: 'count', type: 'int' },
|
||||
];
|
||||
return (
|
||||
<div key={loopId} style={{ marginBottom: 6 }}>
|
||||
<div style={{ fontSize: 11, color: 'var(--text-secondary)', marginBottom: 2 }}>{loopLabel}</div>
|
||||
{loopPaths.map((p, i) => {
|
||||
const compat = expectedParamType && p.type
|
||||
? isCompatible(p.type, expectedParamType)
|
||||
: 'ok';
|
||||
return (
|
||||
<button
|
||||
key={`${loopId}-${p.path.join('.')}-${i}`}
|
||||
type="button"
|
||||
className={styles.dataPickerLeaf}
|
||||
style={{ opacity: compat === 'mismatch' ? 0.45 : 1 }}
|
||||
onClick={() => handlePick(loopId, p.path, p.type)}
|
||||
>
|
||||
{p.label}
|
||||
{p.type && (
|
||||
<span className={styles.dataPickerLeafType}>
|
||||
({p.type})
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{Object.keys(systemVars).length > 0 && (
|
||||
<div className={styles.dataPickerNodeSection}>
|
||||
<button
|
||||
|
|
@ -176,7 +367,7 @@ export const DataPicker: React.FC<DataPickerProps> = ({ open,
|
|||
onClick={() => handlePickSystemVar(key)}
|
||||
title={info.description}
|
||||
>
|
||||
{key} <span style={{ color: '#888', fontSize: 10 }}>({info.type})</span>
|
||||
{key} <span className={styles.dataPickerLeafType}>({info.type})</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
|
@ -199,12 +390,26 @@ export const DataPicker: React.FC<DataPickerProps> = ({ open,
|
|||
const isExpanded = expandedNodes.has(nodeId);
|
||||
|
||||
const resolvedSchema = _resolveSchemaForNode(
|
||||
nodeId, nodes, nodeTypes, connections, catalog,
|
||||
nodeId,
|
||||
nodes,
|
||||
nodeTypes,
|
||||
connections,
|
||||
catalog,
|
||||
);
|
||||
const schemaPaths = _buildPathsFromSchema(resolvedSchema);
|
||||
const paths = schemaPaths.length > 0
|
||||
? schemaPaths
|
||||
: _buildPathsFromPreview(nodeOutputsPreview[nodeId], [], t('(ganze Ausgabe)'));
|
||||
const schemaPaths = _buildPathsFromSchema(resolvedSchema, catalog);
|
||||
const annotated = _markIterableCandidates(
|
||||
schemaPaths.length > 0
|
||||
? schemaPaths
|
||||
: _buildPathsFromPreview(nodeOutputsPreview[nodeId], [], t('(ganze Ausgabe)')),
|
||||
expectedParamType,
|
||||
);
|
||||
const paths = strictFilter && expectedParamType
|
||||
? annotated.filter((p) => {
|
||||
if (p.iterable) return true;
|
||||
if (!p.type) return false;
|
||||
return isCompatible(p.type, expectedParamType) !== 'mismatch';
|
||||
})
|
||||
: annotated;
|
||||
|
||||
return (
|
||||
<div key={nodeId} className={styles.dataPickerNodeSection}>
|
||||
|
|
@ -216,28 +421,52 @@ export const DataPicker: React.FC<DataPickerProps> = ({ open,
|
|||
<span className={styles.dataPickerExpandIcon}>{isExpanded ? '▼' : '▶'}</span>
|
||||
<span className={styles.dataPickerNodeLabel}>{label}</span>
|
||||
{resolvedSchema && (
|
||||
<span style={{ color: '#888', fontSize: 10, marginLeft: 4 }}>
|
||||
<span className={styles.dataPickerNodeSchemaHint}>
|
||||
({resolvedSchema.name})
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
{isExpanded && (
|
||||
<div className={styles.dataPickerTree}>
|
||||
{paths.map((p, i) => (
|
||||
<button
|
||||
key={`${p.path.join('.')}-${i}`}
|
||||
type="button"
|
||||
className={styles.dataPickerLeaf}
|
||||
onClick={() => handlePick(nodeId, p.path)}
|
||||
>
|
||||
{p.label}
|
||||
{p.type && (
|
||||
<span style={{ color: '#888', fontSize: 10, marginLeft: 4 }}>
|
||||
({p.type})
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
{paths.length === 0 && (
|
||||
<div style={{ fontSize: 11, color: 'var(--text-secondary)', padding: '4px 8px' }}>
|
||||
{t('(keine kompatiblen Felder — Filter „Nur kompatible“ deaktivieren)')}
|
||||
</div>
|
||||
)}
|
||||
{paths.map((p, i) => {
|
||||
const compat =
|
||||
expectedParamType && p.type ? isCompatible(p.type, expectedParamType) : 'ok';
|
||||
return (
|
||||
<div
|
||||
key={`${p.path.join('.')}-${i}`}
|
||||
style={{ display: 'flex', alignItems: 'center', gap: 4 }}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.dataPickerLeaf}
|
||||
style={{ opacity: compat === 'mismatch' && !p.iterable ? 0.45 : 1, flex: 1 }}
|
||||
onClick={() => handlePick(nodeId, p.path, p.type)}
|
||||
>
|
||||
{p.label}
|
||||
{p.type && (
|
||||
<span className={styles.dataPickerLeafType}>
|
||||
({p.type})
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
{p.iterable && (
|
||||
<button
|
||||
type="button"
|
||||
className={`${styles.dataPickerLeaf} ${styles.dataPickerIterateBtn}`}
|
||||
onClick={() => handlePickIterate(nodeId, p.path, expectedParamType)}
|
||||
title={t('Pro Element der Liste iterieren (Loop)')}
|
||||
>
|
||||
{t('iterieren')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -248,4 +477,6 @@ export const DataPicker: React.FC<DataPickerProps> = ({ open,
|
|||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
return createPortal(_dialog, document.body);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -358,8 +358,6 @@ function getFormFieldType(
|
|||
if (rawFieldType === 'email') return 'email';
|
||||
if (rawFieldType === 'date' || rawFieldType === 'datetime') return 'date';
|
||||
if (rawFieldType === 'boolean' || rawFieldType === 'checkbox') return 'boolean';
|
||||
if (rawFieldType === 'clickup_tasks') return 'string';
|
||||
if (rawFieldType === 'clickup_status') return 'string';
|
||||
return 'string';
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,243 @@
|
|||
// Copyright (c) 2025 Patrick Motsch
|
||||
// All rights reserved.
|
||||
//
|
||||
// Plan #2 — Track A1.1: Component-level tests for RequiredAttributePicker.
|
||||
// Validates the 0/1/N rendering logic that orchestrates DataPicker selection
|
||||
// + the iterierens-suggestion (T5, T6).
|
||||
//
|
||||
// We mock the two consumed contexts (LanguageContext + Automation2DataFlow)
|
||||
// and the DataPicker child so we can assert on the picker UI in isolation.
|
||||
|
||||
import React from 'react';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import type { CanvasConnection, CanvasNode } from '../../editor/FlowCanvas';
|
||||
import type { NodeType, PortField, PortSchema } from '../../../../api/workflowApi';
|
||||
import type { DataRef, SystemVarRef } from './dataRef';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Module mocks — must be registered before importing the SUT
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
vi.mock('../../../../providers/language/LanguageContext', () => ({
|
||||
useLanguage: () => ({ t: (s: string) => s }),
|
||||
}));
|
||||
|
||||
let _ctxValue: unknown = null;
|
||||
vi.mock('../../context/Automation2DataFlowContext', () => ({
|
||||
useAutomation2DataFlow: () => _ctxValue,
|
||||
}));
|
||||
|
||||
vi.mock('./DataPicker', () => ({
|
||||
DataPicker: (props: {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
onPick: (ref: DataRef | SystemVarRef) => void;
|
||||
}) => {
|
||||
if (!props.open) return null;
|
||||
return (
|
||||
<div data-testid="mock-data-picker">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
props.onPick({ type: 'ref', nodeId: 'picked', path: [], expectedType: 'DocumentList' });
|
||||
props.onClose();
|
||||
}}
|
||||
>
|
||||
mock-pick
|
||||
</button>
|
||||
<button type="button" onClick={props.onClose}>
|
||||
mock-close
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
}));
|
||||
|
||||
// SUT imported AFTER mocks (so mocks are applied)
|
||||
import { RequiredAttributePicker } from './RequiredAttributePicker';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Fixtures
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function _field(name: string, type: string): PortField {
|
||||
return { name, type, description: '', required: false };
|
||||
}
|
||||
|
||||
const _docListSchema: PortSchema = {
|
||||
name: 'DocumentList',
|
||||
fields: [_field('documents', 'List[UdmDocument]'), _field('count', 'int')],
|
||||
};
|
||||
const _udmDocumentSchema: PortSchema = {
|
||||
name: 'UdmDocument',
|
||||
fields: [_field('name', 'str'), _field('mimeType', 'str')],
|
||||
};
|
||||
const _portCatalog: Record<string, PortSchema> = {
|
||||
DocumentList: _docListSchema,
|
||||
UdmDocument: _udmDocumentSchema,
|
||||
};
|
||||
|
||||
function _setContext(opts: {
|
||||
consumerNodeId: string;
|
||||
nodes: CanvasNode[];
|
||||
connections: CanvasConnection[];
|
||||
nodeTypes: NodeType[];
|
||||
}) {
|
||||
_ctxValue = {
|
||||
currentNodeId: opts.consumerNodeId,
|
||||
nodes: opts.nodes,
|
||||
connections: opts.connections,
|
||||
nodeTypes: opts.nodeTypes,
|
||||
portTypeCatalog: _portCatalog,
|
||||
nodeOutputsPreview: {},
|
||||
systemVariables: {},
|
||||
language: 'de',
|
||||
getNodeLabel: (n: { id: string; title?: string }) => n.title ?? n.id,
|
||||
getAvailableSourceIds: () => opts.nodes.map((n) => n.id).filter((id) => id !== opts.consumerNodeId),
|
||||
parseGraphDefinedSchema: () => null,
|
||||
};
|
||||
}
|
||||
|
||||
function _node(id: string, type: string): CanvasNode {
|
||||
return { id, type, title: id, x: 0, y: 0, inputs: 1, outputs: 1, parameters: {} };
|
||||
}
|
||||
|
||||
function _conn(id: string, src: string, tgt: string): CanvasConnection {
|
||||
return { id, sourceId: src, sourceHandle: 0, targetId: tgt, targetHandle: 0 };
|
||||
}
|
||||
|
||||
function _nodeType(id: string, outputSchema: string): NodeType {
|
||||
return {
|
||||
id,
|
||||
label: id,
|
||||
description: id,
|
||||
category: 'test',
|
||||
parameters: [],
|
||||
inputs: 1,
|
||||
outputs: 1,
|
||||
outputPorts: [{ schema: outputSchema }],
|
||||
} as unknown as NodeType;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('RequiredAttributePicker — 0/1/N rendering (T5/T6)', () => {
|
||||
it('shows red "no source" pill when no upstream candidate matches (0-case)', () => {
|
||||
_setContext({
|
||||
consumerNodeId: 'cons',
|
||||
nodes: [_node('cons', 'ai.summarizeDocument')],
|
||||
connections: [],
|
||||
nodeTypes: [_nodeType('ai.summarizeDocument', 'AiResult')],
|
||||
});
|
||||
render(
|
||||
<RequiredAttributePicker
|
||||
label="Document List"
|
||||
expectedType="DocumentList"
|
||||
value={undefined}
|
||||
onChange={() => {}}
|
||||
/>,
|
||||
);
|
||||
expect(
|
||||
screen.getByText(/Keine typkompatible Quelle vorhanden/i),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows auto-bind suggestion when exactly one candidate matches (1-case)', () => {
|
||||
_setContext({
|
||||
consumerNodeId: 'cons',
|
||||
nodes: [_node('up', 'sharepoint.readDocs'), _node('cons', 'ai.summarizeDocument')],
|
||||
connections: [_conn('c1', 'up', 'cons')],
|
||||
nodeTypes: [
|
||||
_nodeType('sharepoint.readDocs', 'DocumentList'),
|
||||
_nodeType('ai.summarizeDocument', 'AiResult'),
|
||||
],
|
||||
});
|
||||
render(
|
||||
<RequiredAttributePicker
|
||||
label="Document List"
|
||||
expectedType="DocumentList"
|
||||
value={undefined}
|
||||
onChange={() => {}}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText(/Vorschlag übernehmen/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows iterieren-suggestion when upstream is List[X] and required is X (T6)', () => {
|
||||
_setContext({
|
||||
consumerNodeId: 'cons',
|
||||
nodes: [_node('up', 'sharepoint.readDocs'), _node('cons', 'ai.summarizeDocument')],
|
||||
connections: [_conn('c1', 'up', 'cons')],
|
||||
nodeTypes: [
|
||||
_nodeType('sharepoint.readDocs', 'DocumentList'),
|
||||
_nodeType('ai.summarizeDocument', 'AiResult'),
|
||||
],
|
||||
});
|
||||
render(
|
||||
<RequiredAttributePicker
|
||||
label="Single document"
|
||||
expectedType="UdmDocument"
|
||||
value={undefined}
|
||||
onChange={() => {}}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText(/iterieren/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders bound chip + "Andere wählen" when value is already a DataRef', async () => {
|
||||
_setContext({
|
||||
consumerNodeId: 'cons',
|
||||
nodes: [_node('up', 'sharepoint.readDocs'), _node('cons', 'ai.summarizeDocument')],
|
||||
connections: [_conn('c1', 'up', 'cons')],
|
||||
nodeTypes: [
|
||||
_nodeType('sharepoint.readDocs', 'DocumentList'),
|
||||
_nodeType('ai.summarizeDocument', 'AiResult'),
|
||||
],
|
||||
});
|
||||
const onChange = vi.fn();
|
||||
render(
|
||||
<RequiredAttributePicker
|
||||
label="Document List"
|
||||
expectedType="DocumentList"
|
||||
value={{ type: 'ref', nodeId: 'up', path: [], expectedType: 'DocumentList' }}
|
||||
onChange={onChange}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText('up')).toBeInTheDocument();
|
||||
const clearButton = screen.getByTitle(/Bindung entfernen/i);
|
||||
await userEvent.click(clearButton);
|
||||
expect(onChange).toHaveBeenCalledWith(null);
|
||||
});
|
||||
|
||||
it('opens DataPicker via "Andere wählen" and forwards the picked ref to onChange', async () => {
|
||||
_setContext({
|
||||
consumerNodeId: 'cons',
|
||||
nodes: [_node('up', 'sharepoint.readDocs'), _node('cons', 'ai.summarizeDocument')],
|
||||
connections: [_conn('c1', 'up', 'cons')],
|
||||
nodeTypes: [
|
||||
_nodeType('sharepoint.readDocs', 'DocumentList'),
|
||||
_nodeType('ai.summarizeDocument', 'AiResult'),
|
||||
],
|
||||
});
|
||||
const onChange = vi.fn();
|
||||
render(
|
||||
<RequiredAttributePicker
|
||||
label="Document List"
|
||||
expectedType="DocumentList"
|
||||
value={{ type: 'ref', nodeId: 'up', path: [], expectedType: 'DocumentList' }}
|
||||
onChange={onChange}
|
||||
/>,
|
||||
);
|
||||
const otherButton = screen.getByText(/Andere wählen…/i);
|
||||
await userEvent.click(otherButton);
|
||||
expect(screen.getByTestId('mock-data-picker')).toBeInTheDocument();
|
||||
await userEvent.click(screen.getByText('mock-pick'));
|
||||
expect(onChange).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ type: 'ref', nodeId: 'picked', expectedType: 'DocumentList' }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,282 @@
|
|||
/**
|
||||
* RequiredAttributePicker — Phase-4 Schicht-4 binding affordance for
|
||||
* required parameters of a Schicht-3 Adapter (Editor-Node).
|
||||
*
|
||||
* 0/1/N logic, applied on the set of typed source candidates:
|
||||
* - 0 candidates → red pill: "Keine typkompatible Quelle vorhanden"
|
||||
* (user must add an upstream node first)
|
||||
* - 1 candidate → auto-bound chip with a "Andere wählen…" override button
|
||||
* (still shown explicitly so the user sees what was chosen)
|
||||
* - N candidates → "Quelle wählen…" button that opens the DataPicker
|
||||
* pre-filtered to the expected type
|
||||
*
|
||||
* The picker also surfaces a "Iterieren als Loop" hint when the expected type
|
||||
* is `X` and an upstream candidate is `List[X]` — see paramValidation.ts.
|
||||
*/
|
||||
|
||||
import React, { useMemo, useState } from 'react';
|
||||
import { useAutomation2DataFlow } from '../../context/Automation2DataFlowContext';
|
||||
import { DataPicker } from './DataPicker';
|
||||
import { createRef, formatRefLabel, isRef, type DataRef, type SystemVarRef } from './dataRef';
|
||||
import { findSourceCandidates, strictlyCompatible, type SourceCandidate } from './paramValidation';
|
||||
import styles from '../../editor/Automation2FlowEditor.module.css';
|
||||
|
||||
import { useLanguage } from '../../../../providers/language/LanguageContext';
|
||||
|
||||
export interface RequiredAttributePickerProps {
|
||||
/** Display label for the parameter (already localized). */
|
||||
label: string;
|
||||
/** Type expected by the bound action argument (e.g. "DocumentList", "str"). */
|
||||
expectedType?: string;
|
||||
/** Current bound value (DataRef, SystemVarRef, or unset). */
|
||||
value: unknown;
|
||||
/** Persist a new binding (or `null` to clear). */
|
||||
onChange: (next: DataRef | SystemVarRef | null) => void;
|
||||
/** Optional description shown beneath the picker. */
|
||||
description?: React.ReactNode;
|
||||
}
|
||||
|
||||
export const RequiredAttributePicker: React.FC<RequiredAttributePickerProps> = ({
|
||||
label,
|
||||
expectedType,
|
||||
value,
|
||||
onChange,
|
||||
description,
|
||||
}) => {
|
||||
const { t } = useLanguage();
|
||||
const ctx = useAutomation2DataFlow();
|
||||
const [pickerOpen, setPickerOpen] = useState(false);
|
||||
|
||||
const consumerNodeId = ctx?.currentNodeId ?? '';
|
||||
const nodes = ctx?.nodes ?? [];
|
||||
const connections = ctx?.connections ?? [];
|
||||
const nodeTypes = ctx?.nodeTypes ?? [];
|
||||
const catalog = ctx?.portTypeCatalog ?? {};
|
||||
|
||||
const allCandidates: SourceCandidate[] = useMemo(() => {
|
||||
if (!consumerNodeId) return [];
|
||||
return findSourceCandidates({
|
||||
consumerNodeId,
|
||||
expectedType,
|
||||
nodes,
|
||||
connections: connections.map((c) => ({
|
||||
id: c.id,
|
||||
sourceId: c.sourceId,
|
||||
sourceHandle: c.sourceHandle,
|
||||
targetId: c.targetId,
|
||||
targetHandle: c.targetHandle,
|
||||
})),
|
||||
nodeTypes,
|
||||
portTypeCatalog: catalog,
|
||||
});
|
||||
}, [consumerNodeId, expectedType, nodes, connections, nodeTypes, catalog]);
|
||||
|
||||
const compatibleCandidates = useMemo(() => strictlyCompatible(allCandidates), [allCandidates]);
|
||||
|
||||
const isBoundRef = isRef(value);
|
||||
const boundLabel = isBoundRef ? formatRefLabel(value as DataRef, nodes) : null;
|
||||
|
||||
// 0/1/N
|
||||
const candidateCount = compatibleCandidates.length;
|
||||
const single = candidateCount === 1 ? compatibleCandidates[0] : null;
|
||||
|
||||
const handleAutoBind = () => {
|
||||
if (!single) return;
|
||||
const ref = createRef(single.nodeId, single.iterable && expectedType ? [...single.path, '*'] : single.path, expectedType);
|
||||
onChange(ref);
|
||||
};
|
||||
|
||||
const handlePicked = (picked: DataRef | SystemVarRef) => {
|
||||
onChange(picked);
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={styles.requiredAttributePicker}
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 4,
|
||||
minWidth: 0,
|
||||
maxWidth: '100%',
|
||||
}}
|
||||
>
|
||||
{/* Header: label always takes the full row (flex-basis 100 %), badge
|
||||
wraps below — prevents long type names like List[ActionDocument]
|
||||
from escaping the panel frame on the right. */}
|
||||
<div style={{ display: 'flex', alignItems: 'baseline', gap: 6, flexWrap: 'wrap' }}>
|
||||
<label
|
||||
style={{
|
||||
fontSize: 12,
|
||||
fontWeight: 600,
|
||||
flex: '1 1 100%',
|
||||
minWidth: 0,
|
||||
overflowWrap: 'anywhere',
|
||||
wordBreak: 'break-word',
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
<span style={{ color: 'var(--danger-color, #dc3545)', marginLeft: 2 }}>*</span>
|
||||
</label>
|
||||
{expectedType && (
|
||||
<span
|
||||
title={t('Erwarteter Typ')}
|
||||
style={{
|
||||
fontSize: 10,
|
||||
fontFamily: 'monospace',
|
||||
color: 'var(--text-secondary, #555)',
|
||||
background: 'var(--bg-secondary, #eee)',
|
||||
border: '1px solid var(--border-color, #e0e0e0)',
|
||||
borderRadius: 4,
|
||||
padding: '1px 6px',
|
||||
maxWidth: '100%',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
>
|
||||
{expectedType}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isBoundRef ? (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6, flexWrap: 'wrap', minWidth: 0 }}>
|
||||
<span
|
||||
title={typeof boundLabel === 'string' ? boundLabel : undefined}
|
||||
style={{
|
||||
padding: '2px 8px',
|
||||
borderRadius: 12,
|
||||
background: 'rgba(40,167,69,0.15)',
|
||||
color: 'var(--success-color, #28a745)',
|
||||
fontSize: 12,
|
||||
fontWeight: 500,
|
||||
maxWidth: '100%',
|
||||
overflowWrap: 'anywhere',
|
||||
wordBreak: 'break-word',
|
||||
lineHeight: 1.4,
|
||||
}}
|
||||
>
|
||||
{boundLabel}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.retryButton}
|
||||
onClick={() => setPickerOpen(true)}
|
||||
style={{ fontSize: 11, padding: '2px 8px', flexShrink: 0 }}
|
||||
>
|
||||
{t('Andere wählen…')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.retryButton}
|
||||
onClick={() => onChange(null)}
|
||||
style={{ fontSize: 11, padding: '2px 8px', flexShrink: 0 }}
|
||||
title={t('Bindung entfernen')}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
) : candidateCount === 0 ? (
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'flex-start',
|
||||
gap: 6,
|
||||
padding: '4px 8px',
|
||||
background: 'rgba(220,53,69,0.12)',
|
||||
color: 'var(--danger-color, #dc3545)',
|
||||
borderRadius: 6,
|
||||
fontSize: 12,
|
||||
minWidth: 0,
|
||||
overflowWrap: 'anywhere',
|
||||
wordBreak: 'break-word',
|
||||
}}
|
||||
>
|
||||
<span aria-hidden="true" style={{ flexShrink: 0 }}>⚠</span>
|
||||
<span style={{ minWidth: 0 }}>
|
||||
{t('Keine typkompatible Quelle vorhanden — füge zuerst einen Knoten ein, der ')}
|
||||
<code style={{ fontFamily: 'monospace', overflowWrap: 'anywhere' }}>{expectedType ?? '?'}</code>
|
||||
{t(' liefert.')}
|
||||
</span>
|
||||
</div>
|
||||
) : single ? (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6, flexWrap: 'wrap', minWidth: 0 }}>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.retryButton}
|
||||
onClick={handleAutoBind}
|
||||
style={{
|
||||
fontSize: 11,
|
||||
padding: '3px 10px',
|
||||
maxWidth: '100%',
|
||||
whiteSpace: 'normal',
|
||||
textAlign: 'left',
|
||||
overflowWrap: 'anywhere',
|
||||
wordBreak: 'break-word',
|
||||
lineHeight: 1.4,
|
||||
}}
|
||||
title={t('Einzige passende Quelle übernehmen')}
|
||||
>
|
||||
{t('Vorschlag übernehmen:')}{' '}
|
||||
<strong>
|
||||
{nodes.find((n) => n.id === single.nodeId)?.title ?? single.nodeId}
|
||||
{single.path.length > 0 ? ' → ' + single.path.map(String).join(' → ') : ''}
|
||||
{single.iterable ? ' [' + t('iterieren') + ']' : ''}
|
||||
</strong>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.retryButton}
|
||||
onClick={() => setPickerOpen(true)}
|
||||
style={{ fontSize: 11, padding: '3px 10px', flexShrink: 0 }}
|
||||
>
|
||||
{t('Andere…')}
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6, flexWrap: 'wrap', minWidth: 0 }}>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.retryButton}
|
||||
onClick={() => setPickerOpen(true)}
|
||||
style={{ fontSize: 11, padding: '3px 10px', maxWidth: '100%' }}
|
||||
>
|
||||
{t('Quelle wählen…')} <span style={{ opacity: 0.6 }}>({candidateCount})</span>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{description && (
|
||||
<div
|
||||
style={{
|
||||
fontSize: 11,
|
||||
color: 'var(--text-tertiary, #888)',
|
||||
overflowWrap: 'anywhere',
|
||||
wordBreak: 'break-word',
|
||||
}}
|
||||
>
|
||||
{description}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{pickerOpen && (
|
||||
<DataPicker
|
||||
open={pickerOpen}
|
||||
onClose={() => setPickerOpen(false)}
|
||||
onPick={(picked) => {
|
||||
handlePicked(picked);
|
||||
setPickerOpen(false);
|
||||
}}
|
||||
availableSourceIds={ctx?.getAvailableSourceIds() ?? []}
|
||||
nodes={nodes}
|
||||
nodeOutputsPreview={ctx?.nodeOutputsPreview ?? {}}
|
||||
getNodeLabel={(n) =>
|
||||
ctx?.getNodeLabel(n as { id: string; title?: string; label?: string; type?: string }) ?? n.id
|
||||
}
|
||||
expectedParamType={expectedType}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
@ -1,294 +0,0 @@
|
|||
/**
|
||||
* Sync input.form / trigger.form fields + ClickUp "Aufgabe erstellen" refs from a selected ClickUp list.
|
||||
*/
|
||||
|
||||
import type { CanvasConnection, CanvasNode } from '../../editor/FlowCanvas';
|
||||
import type { FormField } from './types';
|
||||
import { createRef } from './dataRef';
|
||||
|
||||
export type ClickUpFieldLike = Record<string, unknown>;
|
||||
|
||||
function buildReverseAdjacency(connections: CanvasConnection[]): Record<string, string[]> {
|
||||
const rev: Record<string, string[]> = {};
|
||||
for (const c of connections) {
|
||||
if (!rev[c.targetId]) rev[c.targetId] = [];
|
||||
rev[c.targetId].push(c.sourceId);
|
||||
}
|
||||
return rev;
|
||||
}
|
||||
|
||||
/** Nearest form node upstream (toward triggers) of the ClickUp node. */
|
||||
export function findClosestUpstreamFormNode(
|
||||
targetNodeId: string,
|
||||
nodes: CanvasNode[],
|
||||
connections: CanvasConnection[]
|
||||
): CanvasNode | null {
|
||||
const nodeById = new Map(nodes.map((n) => [n.id, n]));
|
||||
const rev = buildReverseAdjacency(connections);
|
||||
const queue: string[] = [...(rev[targetNodeId] ?? [])];
|
||||
const visited = new Set<string>();
|
||||
while (queue.length > 0) {
|
||||
const nid = queue.shift()!;
|
||||
if (visited.has(nid)) continue;
|
||||
visited.add(nid);
|
||||
const n = nodeById.get(nid);
|
||||
if (!n) continue;
|
||||
if (n.type === 'input.form' || n.type === 'trigger.form') return n;
|
||||
for (const p of rev[nid] ?? []) {
|
||||
if (!visited.has(p)) queue.push(p);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function normalizeClickUpFieldType(raw: unknown): string {
|
||||
return String(raw ?? 'short_text')
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/-/g, '_')
|
||||
.replace(/\s+/g, '_');
|
||||
}
|
||||
|
||||
function linkedListIdFromRelationshipField(field: ClickUpFieldLike): string | null {
|
||||
const tc = (field.type_config ?? {}) as Record<string, unknown>;
|
||||
const asId = (v: unknown): string | null => {
|
||||
if (typeof v === 'string' && v.trim()) return v.trim();
|
||||
if (typeof v === 'number' && Number.isFinite(v)) return String(v);
|
||||
return null;
|
||||
};
|
||||
const keys = [
|
||||
'linked_list_id',
|
||||
'list_id',
|
||||
'related_list_id',
|
||||
'relationship_list_id',
|
||||
'resource_id',
|
||||
];
|
||||
for (const k of keys) {
|
||||
const raw = tc[k];
|
||||
const id = asId(raw);
|
||||
if (id) return id;
|
||||
if (raw && typeof raw === 'object' && raw !== null) {
|
||||
const nested = asId((raw as Record<string, unknown>).id);
|
||||
if (nested) return nested;
|
||||
}
|
||||
}
|
||||
const rel = tc.relationship;
|
||||
if (rel && typeof rel === 'object' && rel !== null) {
|
||||
const r = rel as Record<string, unknown>;
|
||||
const fromRel = asId(r.list_id ?? r.id ?? r.target_id ?? r.linked_list_id ?? r.resource_id);
|
||||
if (fromRel) return fromRel;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function fieldUnsupported(ft: string): boolean {
|
||||
return ['tasks', 'user', 'users'].includes(ft);
|
||||
}
|
||||
|
||||
function mapCuToInputFormField(
|
||||
field: ClickUpFieldLike,
|
||||
connectionId: string,
|
||||
parentListId: string
|
||||
): FormField | null {
|
||||
const fid = String(field.id ?? '');
|
||||
if (!fid) return null;
|
||||
const fname = String(field.name ?? fid);
|
||||
const ft = normalizeClickUpFieldType(field.type);
|
||||
if (fieldUnsupported(ft)) return null;
|
||||
const name = `cf_${fid.replace(/[^a-zA-Z0-9_]/g, '_')}`;
|
||||
const label = fname || name;
|
||||
|
||||
if (ft === 'list_relationship') {
|
||||
const lid = linkedListIdFromRelationshipField(field) ?? parentListId;
|
||||
return {
|
||||
name,
|
||||
label,
|
||||
type: 'clickup_tasks',
|
||||
required: false,
|
||||
clickupConnectionId: connectionId,
|
||||
clickupListId: lid,
|
||||
};
|
||||
}
|
||||
if (
|
||||
ft === 'drop_down' ||
|
||||
ft === 'dropdown' ||
|
||||
ft === 'text' ||
|
||||
ft === 'long_text' ||
|
||||
ft === 'short_text' ||
|
||||
ft === 'email' ||
|
||||
ft === 'phone' ||
|
||||
ft === 'url'
|
||||
) {
|
||||
return { name, label, type: 'string', required: false };
|
||||
}
|
||||
if (ft === 'number' || ft === 'currency') {
|
||||
return { name, label, type: 'number', required: false };
|
||||
}
|
||||
if (ft === 'date') {
|
||||
return { name, label, type: 'date', required: false };
|
||||
}
|
||||
if (ft === 'checkbox') {
|
||||
return { name, label, type: 'boolean', required: false };
|
||||
}
|
||||
return { name, label, type: 'string', required: false };
|
||||
}
|
||||
|
||||
/** trigger.form row; `clickup_status` carries options from the same list API as the ClickUp node dropdown. */
|
||||
export type TriggerFormFieldRow = {
|
||||
name: string;
|
||||
label: string;
|
||||
type: 'text' | 'number' | 'email' | 'date' | 'boolean' | 'clickup_status';
|
||||
statusOptions?: Array<{ value: string; label: string }>;
|
||||
};
|
||||
|
||||
function mapCuToTriggerFormField(field: ClickUpFieldLike, _connectionId: string, _parentListId: string): TriggerFormFieldRow | null {
|
||||
const fid = String(field.id ?? '');
|
||||
if (!fid) return null;
|
||||
const fname = String(field.name ?? fid);
|
||||
const ft = normalizeClickUpFieldType(field.type);
|
||||
if (fieldUnsupported(ft)) return null;
|
||||
const name = `cf_${fid.replace(/[^a-zA-Z0-9_]/g, '_')}`;
|
||||
const label = fname || name;
|
||||
if (ft === 'list_relationship') {
|
||||
return { name, label, type: 'text' };
|
||||
}
|
||||
if (ft === 'number' || ft === 'currency') {
|
||||
return { name, label, type: 'number' };
|
||||
}
|
||||
if (ft === 'date') {
|
||||
return { name, label, type: 'date' };
|
||||
}
|
||||
if (ft === 'checkbox') {
|
||||
return { name, label, type: 'boolean' };
|
||||
}
|
||||
if (ft === 'email') {
|
||||
return { name, label, type: 'email' };
|
||||
}
|
||||
return { name, label, type: 'text' };
|
||||
}
|
||||
|
||||
export const PAYLOAD_TITLE = 'title';
|
||||
export const PAYLOAD_DESCRIPTION = 'description';
|
||||
export const PAYLOAD_STATUS = 'clickup_status';
|
||||
export const PAYLOAD_PRIORITY = 'clickup_priority';
|
||||
export const PAYLOAD_DUE = 'clickup_due_date';
|
||||
export const PAYLOAD_TIME_H = 'clickup_time_estimate_h';
|
||||
|
||||
/** Same ordering as ClickUp list `statuses` (GET /list/{id}). */
|
||||
export function statusOptionsFromListStatuses(
|
||||
listStatuses: Array<{ status: string; orderindex: number }>
|
||||
): Array<{ value: string; label: string }> {
|
||||
return [...listStatuses]
|
||||
.sort((a, b) => a.orderindex - b.orderindex)
|
||||
.map((s) => ({ value: s.status, label: s.status }));
|
||||
}
|
||||
|
||||
export interface SyncFromListResult {
|
||||
inputFormFields: FormField[];
|
||||
triggerFormFields: TriggerFormFieldRow[];
|
||||
clickupPatch: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build form field rows + ClickUp createTask parameter patch (refs → payload.*).
|
||||
*/
|
||||
export function buildSyncFromClickUpList(args: {
|
||||
formNodeId: string;
|
||||
listFields: ClickUpFieldLike[];
|
||||
/** From GET /list/{id} → list.statuses (same source as the ClickUp node status dropdown). */
|
||||
listStatuses: Array<{ status: string; orderindex: number }>;
|
||||
connectionId: string;
|
||||
teamId: string;
|
||||
listId: string;
|
||||
}): SyncFromListResult {
|
||||
const { formNodeId, listFields, listStatuses, connectionId, teamId, listId } = args;
|
||||
const ref = (key: string) => createRef(formNodeId, ['payload', key]);
|
||||
|
||||
const statusOpts = statusOptionsFromListStatuses(listStatuses);
|
||||
|
||||
const standardInput: FormField[] = [
|
||||
{ name: PAYLOAD_TITLE, label: 'Titel', type: 'string', required: true },
|
||||
{ name: PAYLOAD_DESCRIPTION, label: 'Beschreibung', type: 'string', required: false },
|
||||
...(statusOpts.length > 0
|
||||
? [
|
||||
{
|
||||
name: PAYLOAD_STATUS,
|
||||
label: 'Status',
|
||||
type: 'clickup_status',
|
||||
required: false,
|
||||
clickupStatusOptions: statusOpts,
|
||||
} as FormField,
|
||||
]
|
||||
: []),
|
||||
{ name: PAYLOAD_PRIORITY, label: 'Priorität (1–4)', type: 'number', required: false },
|
||||
{ name: PAYLOAD_DUE, label: 'Fälligkeit', type: 'date', required: false },
|
||||
{ name: PAYLOAD_TIME_H, label: 'Zeitschätzung (Stunden)', type: 'number', required: false },
|
||||
];
|
||||
|
||||
const statusTriggerRow: TriggerFormFieldRow | null =
|
||||
statusOpts.length > 0
|
||||
? {
|
||||
name: PAYLOAD_STATUS,
|
||||
label: 'Status',
|
||||
type: 'clickup_status',
|
||||
statusOptions: statusOpts,
|
||||
}
|
||||
: null;
|
||||
|
||||
const standardTrigger: TriggerFormFieldRow[] = [
|
||||
{ name: PAYLOAD_TITLE, label: 'Titel', type: 'text' },
|
||||
{ name: PAYLOAD_DESCRIPTION, label: 'Beschreibung', type: 'text' },
|
||||
...(statusTriggerRow ? [statusTriggerRow] : []),
|
||||
{ name: PAYLOAD_PRIORITY, label: 'Priorität (1–4)', type: 'number' },
|
||||
{ name: PAYLOAD_DUE, label: 'Fälligkeit', type: 'date' },
|
||||
{ name: PAYLOAD_TIME_H, label: 'Zeitschätzung (Stunden)', type: 'number' },
|
||||
];
|
||||
if (statusOpts.length > 0) {
|
||||
standardTrigger.splice(2, 0, {
|
||||
name: PAYLOAD_STATUS,
|
||||
label: 'Status',
|
||||
type: 'clickup_status',
|
||||
statusOptions: statusOpts,
|
||||
});
|
||||
}
|
||||
|
||||
const customInput: FormField[] = [];
|
||||
const customTrigger: TriggerFormFieldRow[] = [];
|
||||
const customRefs: Record<string, unknown> = {};
|
||||
|
||||
for (const f of listFields) {
|
||||
if (!f || typeof f !== 'object') continue;
|
||||
const inf = mapCuToInputFormField(f as ClickUpFieldLike, connectionId, listId);
|
||||
const tr = mapCuToTriggerFormField(f as ClickUpFieldLike, connectionId, listId);
|
||||
if (inf) customInput.push(inf);
|
||||
if (tr) customTrigger.push(tr);
|
||||
const fid = String((f as ClickUpFieldLike).id ?? '');
|
||||
const payloadKey = inf?.name;
|
||||
if (fid && payloadKey) {
|
||||
customRefs[fid] = createRef(formNodeId, ['payload', payloadKey]);
|
||||
}
|
||||
}
|
||||
|
||||
const inputFormFields = [...standardInput, ...customInput];
|
||||
const triggerFormFields = [...standardTrigger, ...customTrigger];
|
||||
|
||||
const clickupPatch: Record<string, unknown> = {
|
||||
connectionId,
|
||||
teamId,
|
||||
listId,
|
||||
path: `/team/${teamId}/list/${listId}`,
|
||||
name: ref(PAYLOAD_TITLE),
|
||||
description: ref(PAYLOAD_DESCRIPTION),
|
||||
taskPriority: ref(PAYLOAD_PRIORITY),
|
||||
taskDueDateMs: ref(PAYLOAD_DUE),
|
||||
taskTimeEstimateHours: ref(PAYLOAD_TIME_H),
|
||||
};
|
||||
if (statusOpts.length > 0) {
|
||||
clickupPatch.taskStatus = ref(PAYLOAD_STATUS);
|
||||
}
|
||||
if (Object.keys(customRefs).length) {
|
||||
clickupPatch.customFieldValues = customRefs;
|
||||
}
|
||||
|
||||
return { inputFormFields, triggerFormFields, clickupPatch };
|
||||
}
|
||||
|
|
@ -8,6 +8,8 @@ export interface DataRef {
|
|||
type: 'ref';
|
||||
nodeId: string;
|
||||
path: (string | number)[];
|
||||
/** Optional declared type at bind time (for UI / validation hints) */
|
||||
expectedType?: string;
|
||||
}
|
||||
|
||||
/** Explicit static value wrapper */
|
||||
|
|
@ -63,8 +65,18 @@ export function createSystemVar(variable: string): SystemVarRef {
|
|||
}
|
||||
|
||||
/** Create a reference object */
|
||||
export function createRef(nodeId: string, path: (string | number)[] = []): DataRef {
|
||||
return { type: 'ref', nodeId, path };
|
||||
export function createRef(nodeId: string, path: (string | number)[] = [], expectedType?: string): DataRef {
|
||||
return { type: 'ref', nodeId, path, ...(expectedType ? { expectedType } : {}) };
|
||||
}
|
||||
|
||||
/** Structural type compatibility (best-effort; same as gateway soft rules). */
|
||||
export function isCompatible(producedType: string, expectedType: string): 'ok' | 'coerce' | 'mismatch' {
|
||||
if (!expectedType || !producedType) return 'ok';
|
||||
if (producedType === expectedType) return 'ok';
|
||||
if (expectedType === 'Any' || producedType === 'Any') return 'ok';
|
||||
if (expectedType === 'str' && (producedType === 'int' || producedType === 'float')) return 'coerce';
|
||||
if (expectedType === 'int' && producedType === 'str') return 'coerce';
|
||||
return 'mismatch';
|
||||
}
|
||||
|
||||
/** Create a value wrapper */
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import type {
|
|||
Automation2Graph,
|
||||
Automation2GraphNode,
|
||||
Automation2Connection,
|
||||
GraphDefinedSchemaRef,
|
||||
} from '../../../../api/workflowApi';
|
||||
import type { CanvasNode, CanvasConnection } from '../../editor/FlowCanvas';
|
||||
|
||||
|
|
@ -42,7 +43,10 @@ export function fromApiGraph(
|
|||
? Object.entries(nt.inputPorts).map(([, v]) => ({ name: '', schema: '', accepts: (v as { accepts?: string[] }).accepts }))
|
||||
: undefined,
|
||||
outputPorts: nt?.outputPorts
|
||||
? Object.entries(nt.outputPorts).map(([, v]) => ({ name: '', schema: (v as { schema?: string }).schema ?? '' }))
|
||||
? Object.entries(nt.outputPorts).map(([, v]) => ({
|
||||
name: '',
|
||||
schema: (v as { schema?: string | GraphDefinedSchemaRef }).schema ?? '',
|
||||
}))
|
||||
: undefined,
|
||||
};
|
||||
});
|
||||
|
|
|
|||
|
|
@ -69,6 +69,10 @@ export function buildNodeOutputPreview(
|
|||
return { _transit: true, _meta: {}, data: {} };
|
||||
}
|
||||
|
||||
if (typeof port0.schema !== 'string') {
|
||||
return {};
|
||||
}
|
||||
|
||||
return _buildSchemaPreview(port0.schema);
|
||||
}
|
||||
|
||||
|
|
|
|||
318
src/components/FlowEditor/nodes/shared/paramValidation.test.ts
Normal file
318
src/components/FlowEditor/nodes/shared/paramValidation.test.ts
Normal file
|
|
@ -0,0 +1,318 @@
|
|||
// Copyright (c) 2025 Patrick Motsch
|
||||
// All rights reserved.
|
||||
//
|
||||
// Plan #2 — Track A1 / FE-Tests
|
||||
// T5/T6 (RequiredAttributePicker): 0/1/N candidate logic + iterierens-suggestion
|
||||
// T7 (DataPicker): strict type filtering
|
||||
// T8 (DataPicker): generic-object drill-down via wildcard segment '*'
|
||||
//
|
||||
// We test the pure helpers in paramValidation.ts directly. The component
|
||||
// pickers are thin shells over these helpers, so covering the helpers covers
|
||||
// the deterministic core of the binding affordance.
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
findGraphErrors,
|
||||
findRequiredErrors,
|
||||
findSourceCandidates,
|
||||
isParamBound,
|
||||
strictlyCompatible,
|
||||
type SourceCandidate,
|
||||
} from './paramValidation';
|
||||
import { createRef, createSystemVar, createValue } from './dataRef';
|
||||
import type { CanvasConnection, CanvasNode } from '../../editor/FlowCanvas';
|
||||
import type { NodeType, PortField, PortSchema } from '../../../../api/workflowApi';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Fixtures
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function _field(name: string, type: string): PortField {
|
||||
return { name, type, description: '', required: false };
|
||||
}
|
||||
|
||||
function _schema(name: string, fields: PortField[]): PortSchema {
|
||||
return { name, fields };
|
||||
}
|
||||
|
||||
const _docListSchema: PortSchema = _schema('DocumentList', [
|
||||
_field('documents', 'List[UdmDocument]'),
|
||||
_field('count', 'int'),
|
||||
]);
|
||||
|
||||
const _udmDocumentSchema: PortSchema = _schema('UdmDocument', [
|
||||
_field('name', 'str'),
|
||||
_field('mimeType', 'str'),
|
||||
_field('sizeBytes', 'int'),
|
||||
]);
|
||||
|
||||
const _aiResultSchema: PortSchema = _schema('AiResult', [
|
||||
_field('text', 'str'),
|
||||
_field('tokensUsed', 'int'),
|
||||
]);
|
||||
|
||||
const _portCatalog: Record<string, PortSchema> = {
|
||||
DocumentList: _docListSchema,
|
||||
UdmDocument: _udmDocumentSchema,
|
||||
AiResult: _aiResultSchema,
|
||||
};
|
||||
|
||||
function _makeNode(id: string, type: string, parameters: Record<string, unknown> = {}): CanvasNode {
|
||||
return {
|
||||
id,
|
||||
type,
|
||||
title: `${id} (${type})`,
|
||||
x: 0,
|
||||
y: 0,
|
||||
inputs: 1,
|
||||
outputs: 1,
|
||||
parameters,
|
||||
};
|
||||
}
|
||||
|
||||
function _makeConnection(id: string, sourceId: string, targetId: string): CanvasConnection {
|
||||
return {
|
||||
id,
|
||||
sourceId,
|
||||
sourceHandle: 0,
|
||||
targetId,
|
||||
targetHandle: 0,
|
||||
};
|
||||
}
|
||||
|
||||
function _makeNodeType(
|
||||
id: string,
|
||||
outputSchema: string,
|
||||
parameters: NodeType['parameters'] = [],
|
||||
): NodeType {
|
||||
return {
|
||||
id,
|
||||
label: id,
|
||||
description: id,
|
||||
category: 'test',
|
||||
parameters,
|
||||
inputs: 1,
|
||||
outputs: 1,
|
||||
outputPorts: [{ schema: outputSchema }],
|
||||
} as unknown as NodeType;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// isParamBound
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('isParamBound', () => {
|
||||
it('returns false for null/undefined/empty string', () => {
|
||||
expect(isParamBound(null)).toBe(false);
|
||||
expect(isParamBound(undefined)).toBe(false);
|
||||
expect(isParamBound('')).toBe(false);
|
||||
});
|
||||
|
||||
it('returns true for non-empty string/number/boolean', () => {
|
||||
expect(isParamBound('hello')).toBe(true);
|
||||
expect(isParamBound(0)).toBe(true);
|
||||
expect(isParamBound(false)).toBe(true);
|
||||
});
|
||||
|
||||
it('returns true for a valid DataRef and false for one without nodeId', () => {
|
||||
expect(isParamBound(createRef('node-1', ['x']))).toBe(true);
|
||||
expect(isParamBound({ type: 'ref', nodeId: '', path: [] })).toBe(false);
|
||||
});
|
||||
|
||||
it('returns true for a SystemVarRef with a variable name', () => {
|
||||
expect(isParamBound(createSystemVar('user.id'))).toBe(true);
|
||||
expect(isParamBound({ type: 'system', variable: '' })).toBe(false);
|
||||
});
|
||||
|
||||
it('treats {type:"value", value:""} as unbound but {value:0} as bound', () => {
|
||||
expect(isParamBound(createValue(''))).toBe(false);
|
||||
expect(isParamBound(createValue(0))).toBe(true);
|
||||
expect(isParamBound(createValue([]))).toBe(false);
|
||||
expect(isParamBound(createValue(['a']))).toBe(true);
|
||||
});
|
||||
|
||||
it('counts non-empty arrays/objects as bound', () => {
|
||||
expect(isParamBound([])).toBe(false);
|
||||
expect(isParamBound([1])).toBe(true);
|
||||
expect(isParamBound({})).toBe(false);
|
||||
expect(isParamBound({ k: 1 })).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// findRequiredErrors / findGraphErrors
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('findRequiredErrors', () => {
|
||||
it('returns empty when all required params are bound', () => {
|
||||
const node = _makeNode('n1', 'ai.process', {
|
||||
aiPrompt: 'hello',
|
||||
documentList: createRef('upstream', ['documents']),
|
||||
});
|
||||
const nodeType = _makeNodeType('ai.process', 'AiResult', [
|
||||
{ name: 'aiPrompt', type: 'str', required: true },
|
||||
{ name: 'documentList', type: 'DocumentList', required: true },
|
||||
{ name: 'optional', type: 'str', required: false },
|
||||
]);
|
||||
expect(findRequiredErrors(node, nodeType)).toEqual([]);
|
||||
});
|
||||
|
||||
it('flags every unbound required param with its name + type', () => {
|
||||
const node = _makeNode('n1', 'ai.process', {});
|
||||
const nodeType = _makeNodeType('ai.process', 'AiResult', [
|
||||
{ name: 'aiPrompt', type: 'str', required: true },
|
||||
{ name: 'documentList', type: 'DocumentList', required: true },
|
||||
{ name: 'optional', type: 'str', required: false },
|
||||
]);
|
||||
const errs = findRequiredErrors(node, nodeType);
|
||||
expect(errs).toHaveLength(2);
|
||||
expect(errs.map((e) => e.paramName)).toEqual(['aiPrompt', 'documentList']);
|
||||
});
|
||||
|
||||
it('returns empty list when nodeType is unknown', () => {
|
||||
const node = _makeNode('n1', 'ghost.node');
|
||||
expect(findRequiredErrors(node, undefined)).toEqual([]);
|
||||
});
|
||||
|
||||
it('skips required params with frontendType="hidden" (UI safety net)', () => {
|
||||
// Hidden params have no UI surface, so reporting them as
|
||||
// "Pflichtfeld ohne Quelle" would create a phantom error the user can
|
||||
// not resolve. They are auto-set by adapters / system defaults.
|
||||
const node = _makeNode('n1', 'trustee.extractFromFiles', {});
|
||||
const nodeType = _makeNodeType('trustee.extractFromFiles', 'AiResult', [
|
||||
{ name: 'prompt', type: 'str', required: true },
|
||||
{ name: 'systemContext', type: 'str', required: true, frontendType: 'hidden' },
|
||||
]);
|
||||
const errs = findRequiredErrors(node, nodeType);
|
||||
expect(errs).toHaveLength(1);
|
||||
expect(errs[0]!.paramName).toBe('prompt');
|
||||
});
|
||||
});
|
||||
|
||||
describe('findGraphErrors', () => {
|
||||
it('aggregates per-node errors and omits clean nodes', () => {
|
||||
const cleanNodeType = _makeNodeType('clean.node', 'AiResult', [
|
||||
{ name: 'p1', type: 'str', required: true },
|
||||
]);
|
||||
const dirtyNodeType = _makeNodeType('dirty.node', 'AiResult', [
|
||||
{ name: 'p1', type: 'str', required: true },
|
||||
{ name: 'p2', type: 'str', required: true },
|
||||
]);
|
||||
const nodes: CanvasNode[] = [
|
||||
_makeNode('clean', 'clean.node', { p1: 'value' }),
|
||||
_makeNode('dirty', 'dirty.node', { p1: 'set' }),
|
||||
];
|
||||
const result = findGraphErrors(nodes, [cleanNodeType, dirtyNodeType]);
|
||||
expect(Object.keys(result)).toEqual(['dirty']);
|
||||
expect(result['dirty']!.map((e) => e.paramName)).toEqual(['p2']);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// findSourceCandidates — T5/T6/T7/T8 core
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('findSourceCandidates', () => {
|
||||
function _makeFixture() {
|
||||
const upstreamType = _makeNodeType('sharepoint.readDocs', 'DocumentList');
|
||||
const consumerType = _makeNodeType('ai.summarize', 'AiResult', [
|
||||
{ name: 'documentList', type: 'DocumentList', required: true },
|
||||
]);
|
||||
const upstream = _makeNode('up', 'sharepoint.readDocs');
|
||||
const consumer = _makeNode('cons', 'ai.summarize');
|
||||
const conns = [_makeConnection('c1', 'up', 'cons')];
|
||||
return { nodes: [upstream, consumer], connections: conns, nodeTypes: [upstreamType, consumerType] };
|
||||
}
|
||||
|
||||
it('returns the whole-output candidate first (path=[]) for the upstream', () => {
|
||||
const f = _makeFixture();
|
||||
const candidates = findSourceCandidates({
|
||||
consumerNodeId: 'cons',
|
||||
expectedType: 'DocumentList',
|
||||
...f,
|
||||
portTypeCatalog: _portCatalog,
|
||||
});
|
||||
const wholeOutput = candidates.find((c) => c.nodeId === 'up' && c.path.length === 0);
|
||||
expect(wholeOutput).toBeDefined();
|
||||
expect(wholeOutput!.type).toBe('DocumentList');
|
||||
expect(wholeOutput!.compat).toBe('ok');
|
||||
});
|
||||
|
||||
it('drills into List[X] elements via wildcard "*" segment (T8 generic drill-down)', () => {
|
||||
const f = _makeFixture();
|
||||
const candidates = findSourceCandidates({
|
||||
consumerNodeId: 'cons',
|
||||
expectedType: 'str',
|
||||
...f,
|
||||
portTypeCatalog: _portCatalog,
|
||||
});
|
||||
const wildcardCandidate = candidates.find(
|
||||
(c) =>
|
||||
c.nodeId === 'up' &&
|
||||
c.path[0] === 'documents' &&
|
||||
c.path[1] === '*' &&
|
||||
c.path[2] === 'name',
|
||||
);
|
||||
expect(wildcardCandidate).toBeDefined();
|
||||
expect(wildcardCandidate!.type).toBe('str');
|
||||
expect(wildcardCandidate!.compat).toBe('ok');
|
||||
});
|
||||
|
||||
it('marks List[X] → X as iterable (T6 "iterieren"-Vorschlag)', () => {
|
||||
const f = _makeFixture();
|
||||
const candidates = findSourceCandidates({
|
||||
consumerNodeId: 'cons',
|
||||
expectedType: 'UdmDocument',
|
||||
...f,
|
||||
portTypeCatalog: _portCatalog,
|
||||
});
|
||||
const iterable = candidates.find(
|
||||
(c) => c.nodeId === 'up' && c.path.length === 1 && c.path[0] === 'documents' && c.iterable,
|
||||
);
|
||||
expect(iterable).toBeDefined();
|
||||
expect(iterable!.type).toBe('List[UdmDocument]');
|
||||
});
|
||||
|
||||
it('returns no candidates when no upstream is connected (T5: 0-case)', () => {
|
||||
const f = _makeFixture();
|
||||
const isolated = _makeNode('iso', 'ai.summarize');
|
||||
const candidates = findSourceCandidates({
|
||||
consumerNodeId: 'iso',
|
||||
expectedType: 'DocumentList',
|
||||
nodes: [...f.nodes, isolated],
|
||||
connections: f.connections,
|
||||
nodeTypes: f.nodeTypes,
|
||||
portTypeCatalog: _portCatalog,
|
||||
});
|
||||
expect(candidates).toEqual([]);
|
||||
});
|
||||
|
||||
it('returns plain candidates (compat="ok") when expectedType is omitted', () => {
|
||||
const f = _makeFixture();
|
||||
const candidates = findSourceCandidates({
|
||||
consumerNodeId: 'cons',
|
||||
...f,
|
||||
portTypeCatalog: _portCatalog,
|
||||
});
|
||||
expect(candidates.length).toBeGreaterThan(0);
|
||||
expect(candidates.every((c) => c.compat === 'ok')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// strictlyCompatible — T7 strict type filter
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('strictlyCompatible', () => {
|
||||
it('keeps only ok / coerce / iterable candidates and drops mismatch', () => {
|
||||
const all: SourceCandidate[] = [
|
||||
{ nodeId: 'a', path: [], type: 'DocumentList', compat: 'ok' },
|
||||
{ nodeId: 'a', path: ['documents'], type: 'List[UdmDocument]', compat: 'mismatch', iterable: true },
|
||||
{ nodeId: 'a', path: ['count'], type: 'int', compat: 'coerce' },
|
||||
{ nodeId: 'a', path: ['junk'], type: 'object', compat: 'mismatch' },
|
||||
];
|
||||
const out = strictlyCompatible(all);
|
||||
expect(out.map((c) => c.path)).toEqual([[], ['documents'], ['count']]);
|
||||
});
|
||||
});
|
||||
216
src/components/FlowEditor/nodes/shared/paramValidation.ts
Normal file
216
src/components/FlowEditor/nodes/shared/paramValidation.ts
Normal file
|
|
@ -0,0 +1,216 @@
|
|||
/**
|
||||
* Phase-4 Schicht-4 (Instanz-Bindings) — Validation utilities.
|
||||
*
|
||||
* Single source of truth for two questions every UI surface needs to answer:
|
||||
* 1. "Is this required parameter on this node bound to anything?"
|
||||
* 2. "Which upstream nodes are type-compatible sources for this parameter?"
|
||||
*
|
||||
* Used by:
|
||||
* - RequiredAttributePicker (renders 0/1/N affordance based on candidate count)
|
||||
* - NodeConfigPanel (orders required params first, surfaces missing-source pill)
|
||||
* - FlowCanvas (red error badge per node when any required param is unbound)
|
||||
* - CanvasHeader (Run button disabled when any node has unbound required params)
|
||||
*
|
||||
* The required check is deliberately conservative: a param counts as "bound"
|
||||
* if it has any non-empty value, a non-empty static value-wrapper, a ref, or a
|
||||
* system-var ref. Empty string / null / undefined / { type: 'value', value: '' }
|
||||
* all count as unbound.
|
||||
*/
|
||||
|
||||
import type { CanvasConnection, CanvasNode } from '../../editor/FlowCanvas';
|
||||
import type { GraphDefinedSchemaRef, NodeType, NodeTypeParameter, OutputPortDef, PortSchema } from '../../../../api/workflowApi';
|
||||
import { isCompatible, isRef, isSystemVar, isValue } from './dataRef';
|
||||
import { getAvailableSources } from './dataFlowGraph';
|
||||
|
||||
const _LIST_INNER_RE = /^List\[(.+)\]$/;
|
||||
|
||||
/** A candidate path on an upstream node that could satisfy a parameter binding. */
|
||||
export interface SourceCandidate {
|
||||
nodeId: string;
|
||||
/** JSON path on the node output, e.g. ['documents', 0, 'name']. */
|
||||
path: (string | number)[];
|
||||
/** Type as declared by the schema field at this path (best-effort). */
|
||||
type?: string;
|
||||
/** Compatibility verdict against the requested type. */
|
||||
compat: 'ok' | 'coerce' | 'mismatch';
|
||||
/** True iff the candidate is a List that, by element-iteration ('*'), would
|
||||
* satisfy the requested scalar type — the "iterieren als Loop-Vorschlag". */
|
||||
iterable?: boolean;
|
||||
}
|
||||
|
||||
/** Decide whether a parameter value counts as "bound" for required-check purposes. */
|
||||
export function isParamBound(value: unknown): boolean {
|
||||
if (value === null || value === undefined) return false;
|
||||
if (typeof value === 'string') return value.length > 0;
|
||||
if (typeof value === 'number' || typeof value === 'boolean') return true;
|
||||
if (isRef(value)) return Boolean(value.nodeId);
|
||||
if (isSystemVar(value)) return Boolean(value.variable);
|
||||
if (isValue(value)) {
|
||||
const inner = value.value;
|
||||
if (inner === null || inner === undefined) return false;
|
||||
if (typeof inner === 'string') return inner.length > 0;
|
||||
if (Array.isArray(inner)) return inner.length > 0;
|
||||
return true;
|
||||
}
|
||||
if (Array.isArray(value)) return value.length > 0;
|
||||
if (typeof value === 'object') return Object.keys(value as object).length > 0;
|
||||
return false;
|
||||
}
|
||||
|
||||
/** A "required" param on a node that has no value and no incoming binding. */
|
||||
export interface RequiredParamError {
|
||||
paramName: string;
|
||||
paramLabel: string;
|
||||
paramType?: string;
|
||||
}
|
||||
|
||||
/** Walk a node's parameter spec + values and flag every required-but-unbound.
|
||||
*
|
||||
* Safety net: params with `frontendType: 'hidden'` are excluded — they have
|
||||
* no UI surface (the panel skips them entirely), so reporting them as
|
||||
* "Pflichtfeld ohne Quelle" would create a phantom error the user cannot
|
||||
* resolve. Hidden-required params should be auto-set by the adapter or
|
||||
* caught in tests, never surfaced to end users.
|
||||
*/
|
||||
export function findRequiredErrors(
|
||||
node: CanvasNode,
|
||||
nodeType: NodeType | undefined,
|
||||
resolveLabel: (param: NodeTypeParameter) => string = (p) => p.name,
|
||||
): RequiredParamError[] {
|
||||
if (!nodeType) return [];
|
||||
const errors: RequiredParamError[] = [];
|
||||
const values = node.parameters ?? {};
|
||||
for (const param of nodeType.parameters ?? []) {
|
||||
if (!param.required) continue;
|
||||
if (param.frontendType === 'hidden') continue;
|
||||
if (isParamBound(values[param.name])) continue;
|
||||
errors.push({ paramName: param.name, paramLabel: resolveLabel(param), paramType: param.type });
|
||||
}
|
||||
return errors;
|
||||
}
|
||||
|
||||
/** Map of nodeId → required errors. Empty entries are omitted. */
|
||||
export function findGraphErrors(
|
||||
nodes: CanvasNode[],
|
||||
nodeTypes: NodeType[],
|
||||
resolveLabel?: (param: NodeTypeParameter) => string,
|
||||
): Record<string, RequiredParamError[]> {
|
||||
const byId: Record<string, RequiredParamError[]> = {};
|
||||
const byTypeId = new Map(nodeTypes.map((nt) => [nt.id, nt]));
|
||||
for (const n of nodes) {
|
||||
const errs = findRequiredErrors(n, byTypeId.get(n.type), resolveLabel);
|
||||
if (errs.length) byId[n.id] = errs;
|
||||
}
|
||||
return byId;
|
||||
}
|
||||
|
||||
/** Resolve the schema produced by an output port (Transit follows incoming connection). */
|
||||
function _resolveOutputSchemaName(
|
||||
nodeId: string,
|
||||
nodes: CanvasNode[],
|
||||
connections: CanvasConnection[],
|
||||
nodeTypes: NodeType[],
|
||||
visited: Set<string> = new Set(),
|
||||
): { schemaName?: string; node?: CanvasNode; portDef?: OutputPortDef } {
|
||||
if (visited.has(nodeId)) return {};
|
||||
visited.add(nodeId);
|
||||
const node = nodes.find((n) => n.id === nodeId);
|
||||
if (!node) return {};
|
||||
const typeDef = nodeTypes.find((nt) => nt.id === node.type);
|
||||
const port0 = typeDef?.outputPorts?.[0] as OutputPortDef | undefined;
|
||||
if (!port0) return { node };
|
||||
const spec = port0.schema as string | GraphDefinedSchemaRef | undefined;
|
||||
if (typeof spec === 'object' && spec !== null && spec.kind === 'fromGraph') {
|
||||
return { schemaName: 'FormPayload_dynamic', node, portDef: port0 };
|
||||
}
|
||||
if (port0.dynamic) {
|
||||
return { schemaName: 'FormPayload_dynamic', node, portDef: port0 };
|
||||
}
|
||||
if (typeof spec === 'string' && spec !== 'Transit') {
|
||||
return { schemaName: spec, node, portDef: port0 };
|
||||
}
|
||||
// Transit: follow upstream
|
||||
const incoming = connections.find((c) => c.targetId === nodeId);
|
||||
if (!incoming) return { node };
|
||||
return _resolveOutputSchemaName(incoming.sourceId, nodes, connections, nodeTypes, visited);
|
||||
}
|
||||
|
||||
/** Build candidate paths from a schema, recursing into List-element schemas one level deep. */
|
||||
function _candidatesFromSchema(
|
||||
schema: PortSchema | undefined,
|
||||
catalog: Record<string, PortSchema>,
|
||||
basePath: (string | number)[] = [],
|
||||
depth = 0,
|
||||
): Array<{ path: (string | number)[]; type?: string }> {
|
||||
if (!schema || !schema.fields || depth > 6) return [];
|
||||
const out: Array<{ path: (string | number)[]; type?: string }> = [];
|
||||
for (const field of schema.fields) {
|
||||
const fieldPath = [...basePath, field.name];
|
||||
out.push({ path: fieldPath, type: field.type });
|
||||
const inner = typeof field.type === 'string' ? field.type.match(_LIST_INNER_RE)?.[1]?.trim() : undefined;
|
||||
if (inner && catalog[inner]) {
|
||||
out.push(..._candidatesFromSchema(catalog[inner], catalog, [...fieldPath, '*'], depth + 1));
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute every typed source candidate that could satisfy `expectedType`
|
||||
* for the given consumer node. Includes ranked compatibility per candidate
|
||||
* and a `iterable` flag for List-X→X "iterate as Loop" suggestions.
|
||||
*
|
||||
* If `expectedType` is omitted, returns all candidates (all marked 'ok').
|
||||
*/
|
||||
export function findSourceCandidates(args: {
|
||||
consumerNodeId: string;
|
||||
expectedType?: string;
|
||||
nodes: CanvasNode[];
|
||||
connections: CanvasConnection[];
|
||||
nodeTypes: NodeType[];
|
||||
portTypeCatalog: Record<string, PortSchema>;
|
||||
}): SourceCandidate[] {
|
||||
const { consumerNodeId, expectedType, nodes, connections, nodeTypes, portTypeCatalog } = args;
|
||||
const sourceIds = getAvailableSources(consumerNodeId, nodes, connections).filter((id) => {
|
||||
const n = nodes.find((x) => x.id === id);
|
||||
return n?.type !== 'trigger.manual';
|
||||
});
|
||||
|
||||
const results: SourceCandidate[] = [];
|
||||
for (const nid of sourceIds) {
|
||||
const { schemaName } = _resolveOutputSchemaName(nid, nodes, connections, nodeTypes);
|
||||
const schema = schemaName ? portTypeCatalog[schemaName] : undefined;
|
||||
const wholeType = schemaName ?? undefined;
|
||||
results.push({
|
||||
nodeId: nid,
|
||||
path: [],
|
||||
type: wholeType,
|
||||
compat: expectedType && wholeType ? isCompatible(wholeType, expectedType) : 'ok',
|
||||
iterable: _isIterableMatch(wholeType, expectedType),
|
||||
});
|
||||
for (const cand of _candidatesFromSchema(schema, portTypeCatalog)) {
|
||||
const compat = expectedType && cand.type ? isCompatible(cand.type, expectedType) : 'ok';
|
||||
results.push({
|
||||
nodeId: nid,
|
||||
path: cand.path,
|
||||
type: cand.type,
|
||||
compat,
|
||||
iterable: _isIterableMatch(cand.type, expectedType),
|
||||
});
|
||||
}
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
/** True iff `producedType` is `List[X]` and `expectedType` equals `X`. */
|
||||
function _isIterableMatch(producedType?: string, expectedType?: string): boolean {
|
||||
if (!producedType || !expectedType) return false;
|
||||
const m = producedType.match(_LIST_INNER_RE);
|
||||
if (!m) return false;
|
||||
return m[1].trim() === expectedType;
|
||||
}
|
||||
|
||||
/** Filter candidates to only those that satisfy `expectedType` (strict mode). */
|
||||
export function strictlyCompatible(candidates: SourceCandidate[]): SourceCandidate[] {
|
||||
return candidates.filter((c) => c.compat === 'ok' || c.compat === 'coerce' || c.iterable === true);
|
||||
}
|
||||
55
src/components/FlowEditor/nodes/shared/scopeHelpers.ts
Normal file
55
src/components/FlowEditor/nodes/shared/scopeHelpers.ts
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
/**
|
||||
* Lexical scope for DataPicker: ancestor node ids reachable backward on the graph.
|
||||
*/
|
||||
|
||||
export interface GraphEdgeLike {
|
||||
source: string;
|
||||
target: string;
|
||||
}
|
||||
|
||||
export interface GraphNodeLike {
|
||||
id: string;
|
||||
type?: string;
|
||||
}
|
||||
|
||||
/** All node ids that can reach targetNodeId via incoming edges (excluding target). */
|
||||
export function computeAncestorNodeIds(
|
||||
_nodes: GraphNodeLike[],
|
||||
connections: GraphEdgeLike[],
|
||||
targetNodeId: string
|
||||
): Set<string> {
|
||||
const preds = new Map<string, Set<string>>();
|
||||
for (const c of connections) {
|
||||
const src = c.source;
|
||||
const tgt = c.target;
|
||||
if (!src || !tgt) continue;
|
||||
if (!preds.has(tgt)) preds.set(tgt, new Set());
|
||||
preds.get(tgt)!.add(src);
|
||||
}
|
||||
const seen = new Set<string>();
|
||||
const stack = [targetNodeId];
|
||||
while (stack.length) {
|
||||
const cur = stack.pop()!;
|
||||
const ps = preds.get(cur);
|
||||
if (!ps) continue;
|
||||
for (const p of ps) {
|
||||
if (!seen.has(p)) {
|
||||
seen.add(p);
|
||||
stack.push(p);
|
||||
}
|
||||
}
|
||||
}
|
||||
seen.delete(targetNodeId);
|
||||
return seen;
|
||||
}
|
||||
|
||||
/** Node ids of flow.loop ancestors (subset of ancestors). */
|
||||
export function findLoopAncestorIds(
|
||||
nodes: GraphNodeLike[],
|
||||
connections: GraphEdgeLike[],
|
||||
targetNodeId: string
|
||||
): string[] {
|
||||
const anc = computeAncestorNodeIds(nodes, connections, targetNodeId);
|
||||
const byId = new Map(nodes.map((n) => [n.id, n]));
|
||||
return [...anc].filter((id) => byId.get(id)?.type === 'flow.loop');
|
||||
}
|
||||
|
|
@ -3,17 +3,15 @@
|
|||
*/
|
||||
|
||||
import type { ApiRequestFunction } from '../../../../api/workflowApi';
|
||||
import type { AttributeType } from '../../../../utils/attributeTypeMapper';
|
||||
|
||||
/** input.form / trigger.form field row. `clickup_tasks` needs connection + list id; value at runtime is `{ add: [taskId], rem: [] }` (ClickUp relationship). */
|
||||
/** input.form / trigger.form field row. */
|
||||
export type FormField = {
|
||||
name?: string;
|
||||
type?: string;
|
||||
type?: AttributeType;
|
||||
label?: string;
|
||||
required?: boolean;
|
||||
clickupConnectionId?: string;
|
||||
clickupListId?: string;
|
||||
/** ClickUp list status names from GET /list/{id} — only for type `clickup_status`. */
|
||||
clickupStatusOptions?: Array<{ value: string; label: string }>;
|
||||
options?: Array<{ value: string; label: string }>;
|
||||
};
|
||||
|
||||
export interface NodeConfigRendererProps {
|
||||
|
|
|
|||
|
|
@ -4,40 +4,23 @@
|
|||
|
||||
import React, { useMemo } from 'react';
|
||||
import type { NodeConfigRendererProps } from '../shared/types';
|
||||
import type { FormField } from '../shared/types';
|
||||
import { FORM_FIELD_TYPES, FORM_FIELD_TYPE_LABELS } from '../../../../utils/attributeTypeMapper';
|
||||
import styles from '../../editor/Automation2FlowEditor.module.css';
|
||||
|
||||
import { useLanguage } from '../../../../providers/language/LanguageContext';
|
||||
|
||||
type FormField = {
|
||||
name: string;
|
||||
label: string;
|
||||
type: 'text' | 'number' | 'email' | 'date' | 'boolean' | 'clickup_status';
|
||||
statusOptions?: Array<{ value: string; label: string }>;
|
||||
};
|
||||
|
||||
const FORM_FIELD_TYPES = ['text', 'number', 'email', 'date', 'boolean', 'clickup_status'] as const;
|
||||
|
||||
function _parseFields(params: Record<string, unknown>, t: (key: string) => string): FormField[] {
|
||||
const raw = params.formFields;
|
||||
if (!Array.isArray(raw)) return [{ name: 'field1', label: t('Feld 1'), type: 'text' }];
|
||||
return raw.map((f, i) => {
|
||||
if (f && typeof f === 'object' && !Array.isArray(f)) {
|
||||
const o = f as Record<string, unknown>;
|
||||
const fieldType = String(o.type ?? 'text');
|
||||
const rawType = String(o.type ?? 'text');
|
||||
const name = String(o.name ?? `field${i + 1}`);
|
||||
const label = String(o.label ?? `${t('Feld')} ${i + 1}`);
|
||||
const type = (
|
||||
FORM_FIELD_TYPES.includes(fieldType as (typeof FORM_FIELD_TYPES)[number]) ? fieldType : 'text'
|
||||
) as FormField['type'];
|
||||
if (type === 'clickup_status' && Array.isArray(o.statusOptions)) {
|
||||
return {
|
||||
name,
|
||||
label,
|
||||
type: 'clickup_status',
|
||||
statusOptions: o.statusOptions as Array<{ value: string; label: string }>,
|
||||
};
|
||||
}
|
||||
return { name, label, type };
|
||||
const type = (FORM_FIELD_TYPES as readonly string[]).includes(rawType) ? rawType : 'text';
|
||||
return { name, label, type } as FormField;
|
||||
}
|
||||
return { name: `field${i + 1}`, label: `${t('Feld')} ${i + 1}`, type: 'text' as const };
|
||||
});
|
||||
|
|
@ -64,7 +47,7 @@ export const FormStartNodeConfig: React.FC<NodeConfigRendererProps> = ({ params,
|
|||
<input
|
||||
className={styles.startsInput}
|
||||
placeholder={t('Name (Payload-Key)')}
|
||||
value={f.name}
|
||||
value={f.name ?? ''}
|
||||
onChange={(e) => {
|
||||
const next = [...fields];
|
||||
next[idx] = { ...f, name: e.target.value };
|
||||
|
|
@ -74,7 +57,7 @@ export const FormStartNodeConfig: React.FC<NodeConfigRendererProps> = ({ params,
|
|||
<input
|
||||
className={styles.startsInput}
|
||||
placeholder={t('Beschriftung')}
|
||||
value={f.label}
|
||||
value={f.label ?? ''}
|
||||
onChange={(e) => {
|
||||
const next = [...fields];
|
||||
next[idx] = { ...f, label: e.target.value };
|
||||
|
|
@ -83,24 +66,16 @@ export const FormStartNodeConfig: React.FC<NodeConfigRendererProps> = ({ params,
|
|||
/>
|
||||
<select
|
||||
className={styles.startsSelect}
|
||||
value={f.type}
|
||||
value={f.type ?? 'text'}
|
||||
onChange={(e) => {
|
||||
const next = [...fields];
|
||||
const fieldType = e.target.value as FormField['type'];
|
||||
if (fieldType === 'clickup_status') {
|
||||
next[idx] = { name: f.name, label: f.label, type: 'clickup_status', statusOptions: f.statusOptions };
|
||||
} else {
|
||||
next[idx] = { name: f.name, label: f.label, type: fieldType };
|
||||
}
|
||||
next[idx] = { name: f.name, label: f.label, type: e.target.value as FormField['type'] };
|
||||
setFields(next);
|
||||
}}
|
||||
>
|
||||
<option value="text">{t('Text')}</option>
|
||||
<option value="number">{t('Zahl')}</option>
|
||||
<option value="email">{t('E-Mail')}</option>
|
||||
<option value="date">{t('Datum')}</option>
|
||||
<option value="boolean">{t('Ja/Nein')}</option>
|
||||
<option value="clickup_status">{t('ClickUp-Status Liste')}</option>
|
||||
{FORM_FIELD_TYPES.map(ft => (
|
||||
<option key={ft} value={ft}>{t(FORM_FIELD_TYPE_LABELS[ft])}</option>
|
||||
))}
|
||||
</select>
|
||||
<button
|
||||
type="button"
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ import type { AttributeType } from '../../../utils/attributeTypeMapper';
|
|||
// Generic field/column config interface
|
||||
export interface FilterableField {
|
||||
key: string;
|
||||
label: string;
|
||||
label?: string;
|
||||
type?: AttributeType;
|
||||
filterable?: boolean;
|
||||
filterOptions?: string[];
|
||||
|
|
|
|||
|
|
@ -109,6 +109,53 @@
|
|||
border-color: var(--primary-color, #f25843);
|
||||
}
|
||||
|
||||
/* --- Multiselect chip group --- */
|
||||
|
||||
.chipGroup {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
padding: 0.25rem 0.625rem;
|
||||
border: 1px solid var(--border-color, #333);
|
||||
border-radius: 999px;
|
||||
background: var(--bg-secondary, #2a2a2a);
|
||||
color: var(--text-secondary, #888);
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
transition: background 120ms ease, color 120ms ease, border-color 120ms ease;
|
||||
}
|
||||
|
||||
.chip:hover {
|
||||
border-color: var(--primary-color, #f25843);
|
||||
color: var(--text-primary, #e0e0e0);
|
||||
}
|
||||
|
||||
.chipActive {
|
||||
background: var(--primary-color, #4A6FA5);
|
||||
border-color: var(--primary-color, #4A6FA5);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.chipActive:hover {
|
||||
color: #fff;
|
||||
filter: brightness(0.95);
|
||||
}
|
||||
|
||||
.chipMeta {
|
||||
font-size: 0.7rem;
|
||||
color: var(--text-secondary, #888);
|
||||
margin-left: 0.25rem;
|
||||
}
|
||||
|
||||
/* --- Sections Grid --- */
|
||||
|
||||
.sectionsGrid {
|
||||
|
|
|
|||
|
|
@ -667,6 +667,12 @@ const _Toolbar: React.FC<ToolbarProps> = ({
|
|||
value={(filterState.filters[filter.key] as string) || ''}
|
||||
onChange={(e) => _handleFilterChange(filter.key, e.target.value)}
|
||||
/>
|
||||
) : filter.type === 'multiselect' ? (
|
||||
<_MultiselectChips
|
||||
filter={filter}
|
||||
value={filterState.filters[filter.key]}
|
||||
onChange={(next) => _handleFilterChange(filter.key, next)}
|
||||
/>
|
||||
) : (
|
||||
<select
|
||||
className={styles.select}
|
||||
|
|
@ -685,6 +691,73 @@ const _Toolbar: React.FC<ToolbarProps> = ({
|
|||
);
|
||||
};
|
||||
|
||||
// =============================================================================
|
||||
// MULTISELECT CHIPS
|
||||
// Renders ``multiselect`` filters as inline toggle chips so the user can:
|
||||
// - see at a glance which values are active
|
||||
// - toggle individual values on/off
|
||||
// - reset to "all" with the leading "Alle"-chip
|
||||
// Emits the selection upstream as a ``string[]`` matching ``ReportFilterState``.
|
||||
// =============================================================================
|
||||
|
||||
interface _MultiselectChipsProps {
|
||||
filter: ReportFilterConfig;
|
||||
value: string | string[] | undefined;
|
||||
onChange: (next: string[]) => void;
|
||||
}
|
||||
|
||||
const _MultiselectChips: React.FC<_MultiselectChipsProps> = ({ filter, value, onChange }) => {
|
||||
const { t } = useLanguage();
|
||||
const selected: Set<string> = useMemo(() => {
|
||||
if (Array.isArray(value)) return new Set(value.map(String));
|
||||
if (typeof value === 'string' && value !== '') return new Set([value]);
|
||||
return new Set<string>();
|
||||
}, [value]);
|
||||
|
||||
const _toggle = (optValue: string) => {
|
||||
const next = new Set(selected);
|
||||
if (next.has(optValue)) next.delete(optValue); else next.add(optValue);
|
||||
onChange(Array.from(next));
|
||||
};
|
||||
|
||||
const _reset = () => onChange([]);
|
||||
|
||||
const allLabel = filter.placeholder || t('Alle');
|
||||
const totalActive = selected.size;
|
||||
|
||||
return (
|
||||
<div className={styles.chipGroup}>
|
||||
<button
|
||||
type="button"
|
||||
className={`${styles.chip} ${totalActive === 0 ? styles.chipActive : ''}`}
|
||||
onClick={_reset}
|
||||
title={allLabel}
|
||||
>
|
||||
{allLabel}
|
||||
</button>
|
||||
{filter.options?.map(opt => {
|
||||
const active = selected.has(opt.value);
|
||||
return (
|
||||
<button
|
||||
key={opt.value}
|
||||
type="button"
|
||||
className={`${styles.chip} ${active ? styles.chipActive : ''}`}
|
||||
onClick={() => _toggle(opt.value)}
|
||||
title={opt.label}
|
||||
>
|
||||
{opt.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
{totalActive > 0 && (
|
||||
<span className={styles.chipMeta}>
|
||||
{t('{n} aktiv', { n: String(totalActive) })}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// =============================================================================
|
||||
// MAIN COMPONENT
|
||||
// =============================================================================
|
||||
|
|
|
|||
|
|
@ -72,11 +72,11 @@ export interface ReportDateRangeSelectorConfig {
|
|||
* stored selection is available. Default: `'ytd'`.
|
||||
*/
|
||||
defaultPresetKind?:
|
||||
| 'ytd' | 'lastYear' | 'nextYear' | 'last12Months' | 'next12Months'
|
||||
| 'allTime' | 'ytd' | 'lastYear' | 'nextYear' | 'last12Months' | 'next12Months'
|
||||
| 'thisMonth' | 'lastMonth' | 'thisQuarter' | 'lastQuarter' | 'custom';
|
||||
/** Whitelist of preset kinds offered to the user. */
|
||||
enabledPresets?: Array<
|
||||
'ytd' | 'lastYear' | 'nextYear' | 'last12Months' | 'next12Months'
|
||||
'allTime' | 'ytd' | 'lastYear' | 'nextYear' | 'last12Months' | 'next12Months'
|
||||
| 'thisMonth' | 'lastMonth' | 'thisQuarter' | 'lastQuarter'
|
||||
| 'lastN' | 'nextN' | 'custom'
|
||||
>;
|
||||
|
|
|
|||
|
|
@ -133,20 +133,26 @@
|
|||
}
|
||||
|
||||
.table thead tr {
|
||||
background: var(--table-header-bg, #f8f9fa);
|
||||
background: var(--table-header-bg, #edf0f5);
|
||||
}
|
||||
|
||||
.th {
|
||||
background: var(--table-header-bg, #f8f9fa);
|
||||
background: var(--table-header-bg, #edf0f5);
|
||||
padding: 10px 12px;
|
||||
text-align: left;
|
||||
font-weight: 600;
|
||||
font-size: 13px;
|
||||
color: var(--color-text-secondary, #64748b);
|
||||
font-size: 11px;
|
||||
letter-spacing: 0.02em;
|
||||
color: var(--color-text-secondary, #475569);
|
||||
white-space: nowrap;
|
||||
overflow: visible;
|
||||
user-select: none;
|
||||
border-bottom: 2px solid var(--color-border, #e2e8f0);
|
||||
border-bottom: 2px solid rgba(124, 109, 216, 0.35);
|
||||
border-right: 1px solid #dde2ea;
|
||||
}
|
||||
|
||||
.th:last-child {
|
||||
border-right: none;
|
||||
}
|
||||
|
||||
.th.actionsColumn {
|
||||
|
|
@ -159,14 +165,13 @@
|
|||
}
|
||||
|
||||
.th.sortable:hover {
|
||||
background: #eef0f3;
|
||||
background: #e4e8ef;
|
||||
color: var(--color-text, #334155);
|
||||
}
|
||||
|
||||
.headerContent {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: left;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
|
|
@ -230,8 +235,8 @@
|
|||
position: absolute;
|
||||
top: 100%;
|
||||
left: 0;
|
||||
min-width: 180px;
|
||||
max-width: 300px;
|
||||
min-width: 200px;
|
||||
max-width: 320px;
|
||||
background: var(--color-bg);
|
||||
border: 1px solid var(--color-border, #e2e8f0);
|
||||
border-radius: 8px;
|
||||
|
|
@ -303,6 +308,116 @@
|
|||
font-style: italic;
|
||||
}
|
||||
|
||||
/* Numeric column filter (operator + value / range) */
|
||||
.filterNumericPanel {
|
||||
padding: 6px 8px 8px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.filterNumericRow {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.filterNumericLabel {
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
color: var(--color-text-secondary, #64748b);
|
||||
}
|
||||
|
||||
.filterOperatorSelect,
|
||||
.filterNumericInput {
|
||||
width: 100%;
|
||||
padding: 6px 8px;
|
||||
font-size: 13px;
|
||||
font-family: var(--font-family);
|
||||
border: 1px solid var(--color-border, #e2e8f0);
|
||||
border-radius: 6px;
|
||||
background: var(--color-bg);
|
||||
color: var(--color-text);
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.filterOperatorSelect:focus,
|
||||
.filterNumericInput:focus {
|
||||
outline: none;
|
||||
border-color: var(--color-secondary);
|
||||
box-shadow: 0 0 0 2px rgba(var(--color-secondary-rgb), 0.15);
|
||||
}
|
||||
|
||||
.filterNumericActions {
|
||||
padding-top: 2px;
|
||||
}
|
||||
|
||||
.filterApplyBtn {
|
||||
width: 100%;
|
||||
padding: 6px 10px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
font-family: var(--font-family);
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
background: var(--color-secondary);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.filterApplyBtn:hover {
|
||||
opacity: 0.92;
|
||||
}
|
||||
|
||||
/* PeriodPicker wrapper inside filter dropdown (date columns).
|
||||
Rendered as sibling to .filterDropdownOptions so the PeriodPicker
|
||||
popover (position: absolute, ~720 px) is not clipped by overflow. */
|
||||
.filterDatePickerWrap {
|
||||
padding: 6px 8px 8px;
|
||||
overflow: visible;
|
||||
}
|
||||
.filterDatePickerWrap + .filterDropdownOptions {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Date column filter (from / to) — legacy fallback */
|
||||
.filterDatePanel {
|
||||
padding: 6px 8px 8px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.filterDateRow {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.filterDateLabel {
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
color: var(--color-text-secondary, #64748b);
|
||||
}
|
||||
|
||||
.filterDateInput {
|
||||
width: 100%;
|
||||
padding: 6px 8px;
|
||||
font-size: 12px;
|
||||
font-family: var(--font-family);
|
||||
border: 1px solid var(--color-border, #e2e8f0);
|
||||
border-radius: 6px;
|
||||
background: var(--color-bg);
|
||||
color: var(--color-text);
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.filterDateInput:focus {
|
||||
outline: none;
|
||||
border-color: var(--color-secondary);
|
||||
box-shadow: 0 0 0 2px rgba(var(--color-secondary-rgb), 0.15);
|
||||
}
|
||||
|
||||
.resizeHandle {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
|
|
@ -326,7 +441,8 @@
|
|||
/* Table cells */
|
||||
.td {
|
||||
padding: 8px 12px;
|
||||
border-top: 1px solid var(--color-border, #f1f5f9);
|
||||
border-top: 1px solid var(--color-border, #e5e9ef);
|
||||
border-right: 1px solid #eef0f4;
|
||||
color: var(--color-text);
|
||||
font-weight: 400;
|
||||
font-size: 13px;
|
||||
|
|
@ -338,27 +454,27 @@
|
|||
overflow: visible;
|
||||
}
|
||||
|
||||
.fkLoading {
|
||||
color: var(--color-text);
|
||||
opacity: 0.6;
|
||||
font-style: italic;
|
||||
.td:last-child {
|
||||
border-right: none;
|
||||
}
|
||||
|
||||
/* Rows */
|
||||
.tr {
|
||||
transition: background-color 0.12s ease;
|
||||
transition: background-color 0.12s ease, box-shadow 0.12s ease;
|
||||
}
|
||||
|
||||
.tr:hover {
|
||||
background: var(--color-gray-disabled, #f8fafc);
|
||||
background: #f0f4ff;
|
||||
box-shadow: inset 3px 0 0 0 var(--color-secondary);
|
||||
}
|
||||
|
||||
.tr:nth-child(even) {
|
||||
background: rgba(0, 0, 0, 0.015);
|
||||
background: rgba(0, 0, 0, 0.025);
|
||||
}
|
||||
|
||||
.tr:nth-child(even):hover {
|
||||
background: var(--color-gray-disabled, #f8fafc);
|
||||
background: #f0f4ff;
|
||||
box-shadow: inset 3px 0 0 0 var(--color-secondary);
|
||||
}
|
||||
|
||||
.tr.selected {
|
||||
|
|
@ -378,7 +494,7 @@
|
|||
}
|
||||
|
||||
thead .selectColumn {
|
||||
background: var(--table-header-bg, #f8f9fa);
|
||||
background: var(--table-header-bg, #edf0f5);
|
||||
}
|
||||
|
||||
tbody .selectColumn {
|
||||
|
|
@ -429,7 +545,7 @@ tbody .selectColumn {
|
|||
}
|
||||
|
||||
thead .actionsColumn {
|
||||
background: var(--table-header-bg, #f8f9fa);
|
||||
background: var(--table-header-bg, #edf0f5);
|
||||
}
|
||||
|
||||
tbody .actionsColumn {
|
||||
|
|
@ -714,7 +830,11 @@ tbody .actionsColumn {
|
|||
height: auto;
|
||||
}
|
||||
|
||||
.th,
|
||||
.th {
|
||||
padding: 6px 8px;
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.td {
|
||||
padding: 6px 8px;
|
||||
font-size: 12px;
|
||||
|
|
@ -764,29 +884,40 @@ tbody .actionsColumn {
|
|||
/* Dark theme */
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.table thead tr {
|
||||
background: #2a2d31;
|
||||
background: #2d3038;
|
||||
}
|
||||
|
||||
.th {
|
||||
background: #2a2d31;
|
||||
border-bottom-color: rgba(255, 255, 255, 0.12);
|
||||
background: #2d3038;
|
||||
border-bottom: 2px solid rgba(124, 109, 216, 0.3);
|
||||
border-right-color: rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
.td {
|
||||
border-right-color: rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
|
||||
thead .selectColumn,
|
||||
thead .actionsColumn {
|
||||
background: #2a2d31;
|
||||
background: #2d3038;
|
||||
}
|
||||
|
||||
.th.sortable:hover {
|
||||
background: #32363b;
|
||||
background: #363a42;
|
||||
}
|
||||
|
||||
.tr:hover {
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
background: rgba(124, 109, 216, 0.08);
|
||||
box-shadow: inset 3px 0 0 0 var(--color-secondary);
|
||||
}
|
||||
|
||||
.tr:nth-child(even) {
|
||||
background: rgba(255, 255, 255, 0.02);
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
}
|
||||
|
||||
.tr:nth-child(even):hover {
|
||||
background: rgba(124, 109, 216, 0.08);
|
||||
box-shadow: inset 3px 0 0 0 var(--color-secondary);
|
||||
}
|
||||
|
||||
.tr.selected {
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -34,6 +34,9 @@ const _DEFAULT_PRESET: PeriodPreset = { kind: 'ytd' };
|
|||
|
||||
function _formatTriggerLabel(value: PeriodValue | null, t: (k: string) => string, placeholder: string): string {
|
||||
if (!value) return placeholder;
|
||||
// "Alle" intentionally skips the range suffix: the sentinel dates
|
||||
// (1970-2999) would be noise in the trigger.
|
||||
if (value.preset.kind === 'allTime') return t('Alle');
|
||||
const range = `${formatIsoDateDe(value.fromDate)} – ${formatIsoDateDe(value.toDate)}`;
|
||||
switch (value.preset.kind) {
|
||||
case 'ytd': return `${t('Laufendes Jahr')} · ${range}`;
|
||||
|
|
|
|||
|
|
@ -84,9 +84,19 @@ function _shiftBy(d: Date, amount: number, unit: PeriodUnit): Date {
|
|||
// Preset resolver
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// Sentinel bounds used when the user picked ``Alle`` (no date filter). We keep
|
||||
// the values *inside* ``PeriodValue`` so downstream code that reads
|
||||
// ``fromDate``/``toDate`` doesn't break; callers that want to forward "no
|
||||
// filter" to the backend should check ``preset.kind === 'allTime'`` and drop
|
||||
// the dates explicitly before building the request.
|
||||
export const ALL_TIME_FROM = '1970-01-01';
|
||||
export const ALL_TIME_TO = '2999-12-31';
|
||||
|
||||
export function resolvePeriod(preset: PeriodPreset, prevValue?: PeriodValue | null): { fromDate: string; toDate: string } {
|
||||
const today = todayDate();
|
||||
switch (preset.kind) {
|
||||
case 'allTime':
|
||||
return { fromDate: ALL_TIME_FROM, toDate: ALL_TIME_TO };
|
||||
case 'ytd':
|
||||
return { fromDate: toIsoDate(_startOfYear(today)), toDate: toIsoDate(today) };
|
||||
case 'lastYear': {
|
||||
|
|
@ -164,6 +174,20 @@ export function isValueAllowed(value: PeriodValue | null, cfg: PeriodConstraints
|
|||
return true;
|
||||
}
|
||||
|
||||
// Clamp an ISO date to the direction/min/max window defined by ``cfg``. Used
|
||||
// for ``<input type="date">`` ``min``/``max`` attributes so the browser
|
||||
// refuses invalid years instead of us silently falling back to the default
|
||||
// preset afterwards.
|
||||
export function clampIsoDate(_iso: string | undefined, cfg: PeriodConstraints, side: 'min' | 'max'): string | undefined {
|
||||
const today = toIsoDate(todayDate());
|
||||
let lo: string | undefined = cfg.minDate;
|
||||
let hi: string | undefined = cfg.maxDate;
|
||||
if (cfg.direction === 'past') hi = hi && hi < today ? hi : today;
|
||||
if (cfg.direction === 'future') lo = lo && lo > today ? lo : today;
|
||||
if (side === 'min') return lo;
|
||||
return hi;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Label formatting
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
@ -174,6 +198,7 @@ export function isValueAllowed(value: PeriodValue | null, cfg: PeriodConstraints
|
|||
*/
|
||||
export function presetLiteralKey(kind: PeriodPresetKind): string {
|
||||
switch (kind) {
|
||||
case 'allTime': return 'Alle';
|
||||
case 'ytd': return 'Laufendes Jahr';
|
||||
case 'lastYear': return 'Letztes Jahr';
|
||||
case 'nextYear': return 'Nächstes Jahr';
|
||||
|
|
|
|||
|
|
@ -5,10 +5,11 @@
|
|||
* actual commit to the parent via `onApply` / `onCancel`.
|
||||
*/
|
||||
|
||||
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import React, { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react';
|
||||
import { useLanguage } from '../../providers/language/LanguageContext';
|
||||
import PeriodPickerCalendar from './PeriodPickerCalendar';
|
||||
import {
|
||||
clampIsoDate,
|
||||
fromIsoDate,
|
||||
isPresetDisabled,
|
||||
presetLiteralKey,
|
||||
|
|
@ -27,6 +28,7 @@ import type {
|
|||
import styles from './PeriodPicker.module.css';
|
||||
|
||||
const PRESETS_ORDER: PeriodPresetKind[] = [
|
||||
'allTime',
|
||||
'ytd',
|
||||
'lastYear',
|
||||
'nextYear',
|
||||
|
|
@ -41,6 +43,7 @@ const PRESETS_ORDER: PeriodPresetKind[] = [
|
|||
|
||||
function _presetLabel(kind: PeriodPresetKind, t: (k: string) => string): string {
|
||||
switch (kind) {
|
||||
case 'allTime': return t('Alle');
|
||||
case 'ytd': return t('Laufendes Jahr');
|
||||
case 'lastYear': return t('Letztes Jahr');
|
||||
case 'nextYear': return t('Nächstes Jahr');
|
||||
|
|
@ -107,13 +110,21 @@ const PeriodPickerPopover: React.FC<PeriodPickerPopoverProps> = (props) => {
|
|||
const _selectPreset = useCallback((kind: PeriodPresetKind) => {
|
||||
if (isPresetDisabled(kind, constraints)) return;
|
||||
if (kind === 'custom') {
|
||||
// Switching from ``allTime`` back to custom: don't carry the 1970-2999
|
||||
// sentinel. Seed with today/today so the user gets a sensible starting
|
||||
// point and the calendar has a real anchor.
|
||||
const isFromAllTime = draft.preset.kind === 'allTime';
|
||||
const seedFrom = isFromAllTime ? toIsoDate(todayDate()) : draft.fromDate;
|
||||
const seedTo = isFromAllTime ? toIsoDate(todayDate()) : draft.toDate;
|
||||
const next: PeriodValue = {
|
||||
preset: { kind: 'custom' },
|
||||
fromDate: draft.fromDate,
|
||||
toDate: draft.toDate,
|
||||
fromDate: seedFrom,
|
||||
toDate: seedTo,
|
||||
};
|
||||
setDraft(next);
|
||||
setRangePick({ from: fromIsoDate(next.fromDate), to: fromIsoDate(next.toDate) });
|
||||
const anchor = fromIsoDate(seedFrom);
|
||||
if (anchor) setCalAnchor(startOfMonth(anchor));
|
||||
return;
|
||||
}
|
||||
const preset: PeriodPreset = { kind } as PeriodPreset;
|
||||
|
|
@ -152,15 +163,32 @@ const PeriodPickerPopover: React.FC<PeriodPickerPopoverProps> = (props) => {
|
|||
}, [rangePick]);
|
||||
|
||||
const _onFooterFromChange = useCallback((iso: string) => {
|
||||
// Empty string = user cleared the input; ignore so ``draft`` keeps a valid ISO.
|
||||
if (!iso) return;
|
||||
const d = fromIsoDate(iso);
|
||||
setDraft((prev) => ({ ...prev, preset: { kind: 'custom' }, fromDate: iso }));
|
||||
setRangePick((prev) => ({ from: fromIsoDate(iso), to: prev.to }));
|
||||
setRangePick((prev) => ({ from: d, to: prev.to }));
|
||||
// Jump the calendar to the typed month so the user immediately sees the
|
||||
// selection move. Without this, the calendar stays on the current month
|
||||
// and it *looks* like the input was ignored.
|
||||
if (d) setCalAnchor(startOfMonth(d));
|
||||
}, []);
|
||||
|
||||
const _onFooterToChange = useCallback((iso: string) => {
|
||||
if (!iso) return;
|
||||
const d = fromIsoDate(iso);
|
||||
setDraft((prev) => ({ ...prev, preset: { kind: 'custom' }, toDate: iso }));
|
||||
setRangePick((prev) => ({ from: prev.from, to: fromIsoDate(iso) }));
|
||||
setRangePick((prev) => ({ from: prev.from, to: d }));
|
||||
if (d) setCalAnchor(startOfMonth(d));
|
||||
}, []);
|
||||
|
||||
// ``min``/``max`` on the native date inputs — prevents the user from typing
|
||||
// a date that would be silently reverted by the parent's
|
||||
// ``isValueAllowed`` fallback (which would replace it with ``defaultPreset``
|
||||
// and lose the custom year).
|
||||
const footerMin = clampIsoDate(undefined, constraints, 'min');
|
||||
const footerMax = clampIsoDate(undefined, constraints, 'max');
|
||||
|
||||
// Keyboard: Esc cancels, Enter applies
|
||||
const popRef = useRef<HTMLDivElement>(null);
|
||||
useEffect(() => {
|
||||
|
|
@ -172,6 +200,36 @@ const PeriodPickerPopover: React.FC<PeriodPickerPopoverProps> = (props) => {
|
|||
return () => window.removeEventListener('keydown', _onKey);
|
||||
}, [draft, onApply, onCancel]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const pop = popRef.current;
|
||||
if (!pop) return;
|
||||
const _clamp = () => {
|
||||
const parent = pop.parentElement;
|
||||
if (!parent) return;
|
||||
const pRect = parent.getBoundingClientRect();
|
||||
const margin = 8;
|
||||
const popW = pop.offsetWidth || 720;
|
||||
const popH = pop.offsetHeight || 400;
|
||||
let left = pRect.left;
|
||||
let top = pRect.bottom + 6;
|
||||
if (left + popW > window.innerWidth - margin) {
|
||||
left = window.innerWidth - margin - popW;
|
||||
}
|
||||
if (left < margin) left = margin;
|
||||
if (top + popH > window.innerHeight - margin) {
|
||||
top = Math.max(margin, pRect.top - 6 - popH);
|
||||
}
|
||||
pop.style.position = 'fixed';
|
||||
pop.style.left = `${left}px`;
|
||||
pop.style.top = `${top}px`;
|
||||
pop.style.right = 'auto';
|
||||
pop.style.zIndex = '2001';
|
||||
};
|
||||
_clamp();
|
||||
const id = requestAnimationFrame(() => _clamp());
|
||||
return () => cancelAnimationFrame(id);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div ref={popRef} className={styles.popover}>
|
||||
<div className={styles.body}>
|
||||
|
|
@ -269,18 +327,20 @@ const PeriodPickerPopover: React.FC<PeriodPickerPopoverProps> = (props) => {
|
|||
<input
|
||||
type="date"
|
||||
className={styles.footerInput}
|
||||
value={draft.fromDate}
|
||||
min={constraints.minDate}
|
||||
max={constraints.maxDate}
|
||||
value={draft.preset.kind === 'allTime' ? '' : draft.fromDate}
|
||||
min={footerMin}
|
||||
max={footerMax}
|
||||
disabled={draft.preset.kind === 'allTime'}
|
||||
onChange={(e) => _onFooterFromChange(e.target.value)}
|
||||
/>
|
||||
<span className={styles.footerLabel}>{t('Bis')}</span>
|
||||
<input
|
||||
type="date"
|
||||
className={styles.footerInput}
|
||||
value={draft.toDate}
|
||||
min={constraints.minDate}
|
||||
max={constraints.maxDate}
|
||||
value={draft.preset.kind === 'allTime' ? '' : draft.toDate}
|
||||
min={footerMin}
|
||||
max={footerMax}
|
||||
disabled={draft.preset.kind === 'allTime'}
|
||||
onChange={(e) => _onFooterToChange(e.target.value)}
|
||||
/>
|
||||
<span className={styles.spacer} />
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@
|
|||
export type PeriodUnit = 'day' | 'week' | 'month' | 'year';
|
||||
|
||||
export type PeriodPresetKind =
|
||||
| 'allTime'
|
||||
| 'ytd'
|
||||
| 'lastYear'
|
||||
| 'nextYear'
|
||||
|
|
@ -23,6 +24,7 @@ export type PeriodPresetKind =
|
|||
| 'custom';
|
||||
|
||||
export type PeriodPreset =
|
||||
| { kind: 'allTime' }
|
||||
| { kind: 'ytd' }
|
||||
| { kind: 'lastYear' }
|
||||
| { kind: 'nextYear' }
|
||||
|
|
|
|||
|
|
@ -32,8 +32,8 @@ export interface Invitation {
|
|||
roleIds: string[];
|
||||
targetUsername: string;
|
||||
email?: string;
|
||||
createdBy: string;
|
||||
createdAt: number;
|
||||
sysCreatedBy: string;
|
||||
sysCreatedAt: number;
|
||||
expiresAt: number;
|
||||
usedBy?: string;
|
||||
usedAt?: number;
|
||||
|
|
@ -41,9 +41,11 @@ export interface Invitation {
|
|||
maxUses: number;
|
||||
currentUses: number;
|
||||
inviteUrl: string;
|
||||
emailSent?: boolean;
|
||||
isExpired?: boolean;
|
||||
isUsedUp?: boolean;
|
||||
// Backend-driven flags (computed @ Pydantic model + view enrichment)
|
||||
emailSentFlag?: boolean;
|
||||
emailSentAt?: number;
|
||||
expiredFlag?: boolean;
|
||||
usedUpFlag?: boolean;
|
||||
}
|
||||
|
||||
export interface InvitationCreate {
|
||||
|
|
|
|||
|
|
@ -24,6 +24,8 @@ import {
|
|||
import type { AttributeDefinition as FormGenAttr } from '../components/FormGenerator/FormGeneratorForm';
|
||||
import { getMandateBillingFormAttributes } from '../utils/mandateBillingFormMerge';
|
||||
import { validateMandateName } from '../utils/mandateNameUtils';
|
||||
import { resolveColumnTypes } from '../utils/columnTypeResolver';
|
||||
import type { ColumnConfig } from '../components/FormGenerator/FormGeneratorTable';
|
||||
|
||||
// Re-export types
|
||||
export type { Mandate, MandateCreateData, MandateUpdateData, PaginationParams };
|
||||
|
|
@ -153,20 +155,21 @@ export function useAdminMandates() {
|
|||
return await fetchMandateByIdApi(request, mandateId);
|
||||
}, [request]);
|
||||
|
||||
// Generate columns from attributes (including fkSource/fkDisplayField for FK resolution)
|
||||
const columns = attributes.map(attr => ({
|
||||
key: attr.name,
|
||||
label: attr.label || attr.name,
|
||||
type: attr.type as any,
|
||||
sortable: attr.sortable !== false,
|
||||
filterable: attr.filterable !== false,
|
||||
searchable: attr.searchable !== false,
|
||||
width: attr.width || 150,
|
||||
minWidth: attr.minWidth || 100,
|
||||
maxWidth: attr.maxWidth || 400,
|
||||
fkSource: (attr as any).fkSource, // API endpoint for FK data
|
||||
fkDisplayField: (attr as any).fkDisplayField, // Which field of FK target to display
|
||||
}));
|
||||
// Generate columns from attributes (types merged via resolveColumnTypes)
|
||||
const columns: ColumnConfig[] = useMemo(() => {
|
||||
const raw = attributes.map(attr => ({
|
||||
key: attr.name,
|
||||
label: attr.label || attr.name,
|
||||
sortable: attr.sortable !== false,
|
||||
filterable: attr.filterable !== false,
|
||||
searchable: attr.searchable !== false,
|
||||
width: attr.width || 150,
|
||||
minWidth: attr.minWidth || 100,
|
||||
maxWidth: attr.maxWidth || 400,
|
||||
displayField: (attr as any).displayField,
|
||||
}));
|
||||
return resolveColumnTypes(raw, attributes);
|
||||
}, [attributes]);
|
||||
|
||||
// Create mandate
|
||||
const handleCreate = useCallback(async (mandateData: Partial<Mandate>): Promise<Mandate | null> => {
|
||||
|
|
|
|||
|
|
@ -110,6 +110,11 @@ export interface AttributeDefinition {
|
|||
|
||||
interface TrusteeEntityConfig<T> {
|
||||
entityName: string;
|
||||
/** Optional override: name of the *view* model (e.g. ``TrusteePositionView``)
|
||||
* used purely for the `/attributes/...` lookup so synthetic display columns
|
||||
* resolve via `resolveColumnTypes`. Falls back to `entityName` when absent.
|
||||
* Permissions and CRUD operations always use `entityName`. */
|
||||
attributesEntityName?: string;
|
||||
fetchAll: (request: any, instanceId: string, params?: PaginationParams) => Promise<any>;
|
||||
fetchById: (request: any, instanceId: string, id: string) => Promise<T | null>;
|
||||
create: (request: any, instanceId: string, data: Partial<T>) => Promise<T>;
|
||||
|
|
@ -138,7 +143,8 @@ function _createTrusteeEntityHook<T extends { id: string }>(config: TrusteeEntit
|
|||
if (!instanceId) return [];
|
||||
|
||||
try {
|
||||
const response = await api.get(`/api/trustee/${instanceId}/attributes/${config.entityName}`);
|
||||
const attrEntity = config.attributesEntityName ?? config.entityName;
|
||||
const response = await api.get(`/api/trustee/${instanceId}/attributes/${attrEntity}`);
|
||||
let attrs: AttributeDefinition[] = [];
|
||||
if (response.data?.attributes && Array.isArray(response.data.attributes)) {
|
||||
attrs = response.data.attributes;
|
||||
|
|
@ -571,6 +577,9 @@ export const useTrusteeDocumentOperations = _createTrusteeOperationsHook(documen
|
|||
|
||||
const positionConfig: TrusteeEntityConfig<TrusteePosition> = {
|
||||
entityName: 'TrusteePosition',
|
||||
// Use the view model so the table picks up `syncStatus` / `syncErrorMessage`
|
||||
// attributes (computed at the route layer from `TrusteeAccountingSync`).
|
||||
attributesEntityName: 'TrusteePositionView',
|
||||
fetchAll: fetchPositionsApi,
|
||||
fetchById: fetchPositionByIdApi,
|
||||
create: createPositionApi,
|
||||
|
|
|
|||
|
|
@ -16,6 +16,9 @@ import { usePrompt } from '../hooks/usePrompt';
|
|||
import { useApiRequest } from '../hooks/useApi';
|
||||
import { formatUnixTimestamp } from '../utils/time';
|
||||
import { updateWorkflow, executeGraph, deleteSystemWorkflow } from '../api/workflowApi';
|
||||
import { fetchAttributes } from '../api/attributesApi';
|
||||
import type { AttributeDefinition } from '../api/attributesApi';
|
||||
import { resolveColumnTypes } from '../utils/columnTypeResolver';
|
||||
import api from '../api';
|
||||
import { useLanguage } from '../providers/language/LanguageContext';
|
||||
import { useNavigation, type DynamicBlock } from '../hooks/useNavigation';
|
||||
|
|
@ -423,6 +426,7 @@ const _RunTracingModal: React.FC<_RunTracingModalProps> = ({ run, onClose }) =>
|
|||
|
||||
const _DashboardTab: React.FC = () => {
|
||||
const { t } = useLanguage();
|
||||
const { request } = useApiRequest();
|
||||
const { showError } = useToast();
|
||||
|
||||
const [metrics, setMetrics] = useState<WorkflowRunMetrics | null>(null);
|
||||
|
|
@ -431,15 +435,24 @@ const _DashboardTab: React.FC = () => {
|
|||
const [paginationMeta, setPaginationMeta] = useState<any>(null);
|
||||
const [tracingRun, setTracingRun] = useState<WorkflowRun | null>(null);
|
||||
const lastPaginationParamsRef = useRef<any>(null);
|
||||
const [backendAttributes, setBackendAttributes] = useState<AttributeDefinition[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchAttributes(request, 'AutoRun')
|
||||
.then(setBackendAttributes)
|
||||
.catch((err) => { console.error('[automations] fetchAttributes AutoRun failed', err); });
|
||||
}, [request]);
|
||||
|
||||
const _loadMetrics = useCallback(async () => {
|
||||
try {
|
||||
const resp = await api.get('/api/system/workflow-runs/metrics');
|
||||
setMetrics(resp.data);
|
||||
} catch (e) {
|
||||
} catch (e: any) {
|
||||
const msg = e?.response?.data?.detail || e?.message || String(e);
|
||||
console.error('[automations] metrics load failed', e);
|
||||
showError(t('Metriken konnten nicht geladen werden: {msg}', { msg }));
|
||||
}
|
||||
}, []);
|
||||
}, [showError, t]);
|
||||
|
||||
const _loadRuns = useCallback(async (paginationParams?: any) => {
|
||||
if (paginationParams !== undefined) {
|
||||
|
|
@ -518,20 +531,10 @@ const _DashboardTab: React.FC = () => {
|
|||
}
|
||||
}, [showError, t]);
|
||||
|
||||
const _STATUS_LABELS: Record<string, string> = useMemo(() => ({
|
||||
running: t('Laufend'),
|
||||
completed: t('Abgeschlossen'),
|
||||
failed: t('Fehlgeschlagen'),
|
||||
cancelled: t('Abgebrochen'),
|
||||
paused: t('Pausiert'),
|
||||
stopped: t('Gestoppt'),
|
||||
}), [t]);
|
||||
|
||||
const _runColumns: ColumnConfig[] = useMemo(() => [
|
||||
const _rawRunColumns: ColumnConfig[] = useMemo(() => [
|
||||
{
|
||||
key: 'workflowLabel',
|
||||
label: t('Workflow'),
|
||||
type: 'string',
|
||||
width: 200,
|
||||
sortable: true,
|
||||
formatter: (v: string, row: WorkflowRun) => v || row.workflowId || t('—'),
|
||||
|
|
@ -539,55 +542,42 @@ const _DashboardTab: React.FC = () => {
|
|||
{
|
||||
key: 'mandateId',
|
||||
label: t('Mandant'),
|
||||
type: 'string',
|
||||
width: 140,
|
||||
sortable: true,
|
||||
filterable: true,
|
||||
fkSource: '/api/mandates/',
|
||||
fkDisplayField: 'label',
|
||||
displayField: 'mandateLabel',
|
||||
},
|
||||
{
|
||||
key: 'featureInstanceId',
|
||||
label: t('Instanz'),
|
||||
type: 'string',
|
||||
width: 140,
|
||||
sortable: true,
|
||||
filterable: true,
|
||||
fkSource: '/api/features/instances',
|
||||
fkDisplayField: 'label',
|
||||
displayField: 'instanceLabel',
|
||||
},
|
||||
{ key: 'status', width: 110, sortable: true, filterable: true },
|
||||
{
|
||||
key: 'status',
|
||||
label: t('Status'),
|
||||
type: 'string',
|
||||
width: 110,
|
||||
key: 'startedAt',
|
||||
label: t('Gestartet'),
|
||||
width: 150,
|
||||
sortable: true,
|
||||
filterable: true,
|
||||
filterOptions: ['running', 'completed', 'failed', 'cancelled', 'paused'],
|
||||
filterLabelResolver: (v: string) => _STATUS_LABELS[v] || v,
|
||||
formatter: (v: string) => (
|
||||
<span style={{ color: _STATUS_COLORS[v] || 'inherit', fontWeight: 600 }}>
|
||||
{_STATUS_LABELS[v] || v}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'sysCreatedAt',
|
||||
label: t('Gestartet'),
|
||||
type: 'number',
|
||||
width: 150,
|
||||
sortable: true,
|
||||
formatter: (v: number) => _formatTs(v),
|
||||
},
|
||||
{
|
||||
key: 'sysModifiedAt',
|
||||
key: 'completedAt',
|
||||
label: t('Beendet'),
|
||||
type: 'number',
|
||||
width: 150,
|
||||
sortable: true,
|
||||
filterable: true,
|
||||
formatter: (v: number) => _formatTs(v),
|
||||
},
|
||||
], [t, _STATUS_LABELS]);
|
||||
], [t]);
|
||||
|
||||
const _runColumns = useMemo(
|
||||
() => resolveColumnTypes(_rawRunColumns, backendAttributes),
|
||||
[_rawRunColumns, backendAttributes],
|
||||
);
|
||||
|
||||
const _hookData = useMemo(() => ({
|
||||
refetch: _loadRuns,
|
||||
|
|
@ -665,7 +655,7 @@ const _DashboardTab: React.FC = () => {
|
|||
filterable={true}
|
||||
sortable={true}
|
||||
selectable={true}
|
||||
initialSort={[{ key: 'sysCreatedAt', direction: 'desc' }]}
|
||||
initialSort={[{ key: 'startedAt', direction: 'desc' }]}
|
||||
apiEndpoint="/api/system/workflow-runs"
|
||||
customActions={[
|
||||
{
|
||||
|
|
@ -711,6 +701,13 @@ const _WorkflowsTab: React.FC = () => {
|
|||
const [activeFilter, setActiveFilter] = useState<'all' | 'active' | 'inactive'>('all');
|
||||
const [paginationMeta, setPaginationMeta] = useState<any>(null);
|
||||
const lastPaginationParamsRef = useRef<any>(null);
|
||||
const [backendAttributes, setBackendAttributes] = useState<AttributeDefinition[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchAttributes(request, 'Automation2WorkflowView')
|
||||
.then(setBackendAttributes)
|
||||
.catch((err) => { console.error('[automations] fetchAttributes Automation2WorkflowView failed', err); });
|
||||
}, [request]);
|
||||
|
||||
const _load = useCallback(async (paginationParams?: any) => {
|
||||
if (paginationParams !== undefined) {
|
||||
|
|
@ -723,7 +720,7 @@ const _WorkflowsTab: React.FC = () => {
|
|||
if (activeFilter === 'active') params.active = true;
|
||||
if (activeFilter === 'inactive') params.active = false;
|
||||
|
||||
const defaultSort = [{ field: 'createdAt', direction: 'desc' }];
|
||||
const defaultSort = [{ field: 'sysCreatedAt', direction: 'desc' }];
|
||||
const pag = {
|
||||
page: effectiveParams?.page || 1,
|
||||
pageSize: effectiveParams?.pageSize || 25,
|
||||
|
|
@ -818,30 +815,45 @@ const _WorkflowsTab: React.FC = () => {
|
|||
const _handleExecute = useCallback(async (row: SystemWorkflow) => {
|
||||
if (!row.featureInstanceId) return;
|
||||
setExecutingId(row.id);
|
||||
// Track outcome of the fire-and-forget executeGraph promise so the
|
||||
// intermediate "Workflow gestartet" toast is only shown when the call has
|
||||
// not already failed/finished within the 1s observation window. Without
|
||||
// this we always toasted "gestartet" — even when the run had already
|
||||
// errored — producing contradictory toasts and hiding real failures.
|
||||
let observedFailure = false;
|
||||
let observedSuccess = false;
|
||||
try {
|
||||
const invs = row.invocations || [];
|
||||
const primary =
|
||||
invs.find((i) => i.enabled && i.kind === 'manual') ||
|
||||
invs.find((i) => i.enabled && (i.kind === 'form' || i.kind === 'api'));
|
||||
const emptyGraph = { nodes: [], connections: [] };
|
||||
executeGraph(request, row.featureInstanceId, emptyGraph as any, row.id, {
|
||||
const exec = executeGraph(request, row.featureInstanceId, emptyGraph as any, row.id, {
|
||||
...(primary ? { entryPointId: primary.id } : {}),
|
||||
}).then((result) => {
|
||||
if (result?.success) {
|
||||
observedSuccess = true;
|
||||
showSuccess(result?.paused
|
||||
? t('Workflow pausiert bei Human Task.')
|
||||
: t('Workflow abgeschlossen'));
|
||||
} else {
|
||||
observedFailure = true;
|
||||
showError(result?.error || t('Ausführung fehlgeschlagen'));
|
||||
}
|
||||
_load();
|
||||
}).catch((e: any) => {
|
||||
observedFailure = true;
|
||||
showError(t('Fehler: {msg}', { msg: e?.message || t('Ausführung fehlgeschlagen') }));
|
||||
_load();
|
||||
});
|
||||
await new Promise((r) => setTimeout(r, 1000));
|
||||
await Promise.race([
|
||||
exec,
|
||||
new Promise((r) => setTimeout(r, 1000)),
|
||||
]);
|
||||
await _load();
|
||||
showSuccess(t('Workflow gestartet'));
|
||||
if (!observedFailure && !observedSuccess) {
|
||||
showSuccess(t('Workflow gestartet'));
|
||||
}
|
||||
} finally {
|
||||
setExecutingId(null);
|
||||
}
|
||||
|
|
@ -868,14 +880,27 @@ const _WorkflowsTab: React.FC = () => {
|
|||
return invs.some((i) => i.enabled && (i.kind === 'manual' || i.kind === 'api'));
|
||||
}, []);
|
||||
|
||||
const _columns: ColumnConfig[] = useMemo(() => [
|
||||
{ key: 'label', label: t('Workflow'), type: 'string', width: 200, sortable: true, filterable: true },
|
||||
{ key: 'mandateId', label: t('Mandant'), type: 'string', width: 140, sortable: true, filterable: true, fkSource: '/api/mandates/', fkDisplayField: 'label' },
|
||||
{ key: 'featureInstanceId', label: t('Instanz'), type: 'string', width: 140, sortable: true, filterable: true, fkSource: '/api/features/instances', fkDisplayField: 'label' },
|
||||
const _rawColumns: ColumnConfig[] = useMemo(() => [
|
||||
{ key: 'label', label: t('Workflow'), width: 200, sortable: true, filterable: true },
|
||||
{
|
||||
key: 'mandateId',
|
||||
label: t('Mandant'),
|
||||
width: 140,
|
||||
sortable: true,
|
||||
filterable: true,
|
||||
displayField: 'mandateLabel',
|
||||
},
|
||||
{
|
||||
key: 'featureInstanceId',
|
||||
label: t('Instanz'),
|
||||
width: 140,
|
||||
sortable: true,
|
||||
filterable: true,
|
||||
displayField: 'instanceLabel',
|
||||
},
|
||||
{
|
||||
key: 'active',
|
||||
label: t('Aktiv'),
|
||||
type: 'boolean',
|
||||
width: 80,
|
||||
sortable: true,
|
||||
filterable: true,
|
||||
|
|
@ -883,33 +908,41 @@ const _WorkflowsTab: React.FC = () => {
|
|||
{
|
||||
key: 'isRunning',
|
||||
label: t('Läuft'),
|
||||
type: 'boolean',
|
||||
width: 80,
|
||||
sortable: true,
|
||||
filterable: true,
|
||||
},
|
||||
{
|
||||
key: 'sysCreatedAt',
|
||||
label: t('Erstellt'),
|
||||
type: 'number',
|
||||
width: 140,
|
||||
sortable: true,
|
||||
filterable: true,
|
||||
formatter: (v: number) => _formatTs(v),
|
||||
},
|
||||
{
|
||||
key: 'lastStartedAt',
|
||||
label: t('Zuletzt gestartet'),
|
||||
type: 'number',
|
||||
width: 160,
|
||||
sortable: true,
|
||||
filterable: true,
|
||||
formatter: (v: number) => _formatTs(v),
|
||||
},
|
||||
{
|
||||
key: 'runCount',
|
||||
label: t('Läufe'),
|
||||
type: 'number',
|
||||
width: 80,
|
||||
sortable: true,
|
||||
filterable: true,
|
||||
formatter: (v: number) => (v != null ? String(v) : '0'),
|
||||
},
|
||||
], [t]);
|
||||
|
||||
const _columns = useMemo(
|
||||
() => resolveColumnTypes(_rawColumns, backendAttributes),
|
||||
[_rawColumns, backendAttributes],
|
||||
);
|
||||
|
||||
const _hookData = useMemo(() => ({
|
||||
refetch: _load,
|
||||
handleDelete: (id: string) => _handleDelete(id),
|
||||
|
|
@ -954,7 +987,7 @@ const _WorkflowsTab: React.FC = () => {
|
|||
filterable={true}
|
||||
sortable={true}
|
||||
selectable={true}
|
||||
initialSort={[{ key: 'createdAt', direction: 'desc' }]}
|
||||
initialSort={[{ key: 'sysCreatedAt', direction: 'desc' }]}
|
||||
apiEndpoint="/api/system/workflow-runs/workflows"
|
||||
actionButtons={[
|
||||
{
|
||||
|
|
|
|||
|
|
@ -14,6 +14,10 @@ import {
|
|||
} from 'recharts';
|
||||
import { FaDownload, FaEye, FaTrash, FaTimes } from 'react-icons/fa';
|
||||
import api from '../api';
|
||||
import { useApiRequest } from '../hooks/useApi';
|
||||
import { fetchAttributes } from '../api/attributesApi';
|
||||
import type { AttributeDefinition } from '../api/attributesApi';
|
||||
import { resolveColumnTypes } from '../utils/columnTypeResolver';
|
||||
import { useLanguage } from '../providers/language/LanguageContext';
|
||||
import { useUserMandates } from '../hooks/useUserMandates';
|
||||
import { useConfirm } from '../hooks/useConfirm';
|
||||
|
|
@ -139,9 +143,19 @@ const _NEUT_PAGE_SIZE = 100;
|
|||
|
||||
export const ComplianceAuditPage: React.FC = () => {
|
||||
const { t } = useLanguage();
|
||||
const { request } = useApiRequest();
|
||||
const [aiAuditAttrs, setAiAuditAttrs] = useState<AttributeDefinition[]>([]);
|
||||
const [auditLogAttrs, setAuditLogAttrs] = useState<AttributeDefinition[]>([]);
|
||||
const [neutAttrs, setNeutAttrs] = useState<AttributeDefinition[]>([]);
|
||||
const { fetchMandates } = useUserMandates();
|
||||
const { confirm, ConfirmDialog } = useConfirm();
|
||||
|
||||
useEffect(() => {
|
||||
fetchAttributes(request, 'AiAuditLogEntry').then(setAiAuditAttrs).catch(() => setAiAuditAttrs([]));
|
||||
fetchAttributes(request, 'AuditLogEntry').then(setAuditLogAttrs).catch(() => setAuditLogAttrs([]));
|
||||
fetchAttributes(request, 'DataNeutralizerAttributesView').then(setNeutAttrs).catch(() => setNeutAttrs([]));
|
||||
}, [request]);
|
||||
|
||||
const [mandates, setMandates] = useState<Mandate[]>([]);
|
||||
const [mandatesLoading, setMandatesLoading] = useState(true);
|
||||
const [selectedMandateId, setSelectedMandateId] = useState<string | null>(null);
|
||||
|
|
@ -433,19 +447,31 @@ export const ComplianceAuditPage: React.FC = () => {
|
|||
|
||||
// ── Column definitions ──
|
||||
|
||||
const aiLogColumns: ColumnConfig[] = useMemo(() => [
|
||||
{ key: 'timestamp', label: t('Zeitpunkt'), type: 'timestamp' as any, sortable: true, width: 160 },
|
||||
const _rawAiLogColumns: ColumnConfig[] = useMemo(() => [
|
||||
{ key: 'timestamp', label: t('Zeitpunkt'), sortable: true, width: 160 },
|
||||
{
|
||||
key: 'username', label: t('Benutzer'), type: 'text' as any, sortable: true, searchable: true, width: 140,
|
||||
formatter: (val: any, row: any) => val || (row?.userId ? row.userId.slice(0, 8) : '–'),
|
||||
key: 'username',
|
||||
label: t('Benutzer'),
|
||||
sortable: true,
|
||||
searchable: true,
|
||||
width: 140,
|
||||
formatter: (val: any, row: any) => val || (row?.userId ? `NA(${row.userId})` : '–'),
|
||||
},
|
||||
{
|
||||
key: 'instanceLabel', label: t('Feature-Instanz'), type: 'text' as any, sortable: true, filterable: true, width: 160,
|
||||
key: 'instanceLabel',
|
||||
label: t('Feature-Instanz'),
|
||||
sortable: true,
|
||||
filterable: true,
|
||||
width: 160,
|
||||
formatter: (val: any, row: any) => val || row?.featureCode || '–',
|
||||
},
|
||||
{ key: 'aiModel', label: t('AI-Modell'), type: 'text' as any, sortable: true, filterable: true, width: 160 },
|
||||
{ key: 'aiModel', label: t('AI-Modell'), sortable: true, filterable: true, width: 160 },
|
||||
{
|
||||
key: 'aiProvider', label: t('Provider / Typ'), type: 'text' as any, sortable: true, filterable: true, width: 140,
|
||||
key: 'aiProvider',
|
||||
label: t('Provider / Typ'),
|
||||
sortable: true,
|
||||
filterable: true,
|
||||
width: 140,
|
||||
formatter: (val: any, row: any) => {
|
||||
const provider = val || '–';
|
||||
const op = row?.operationType;
|
||||
|
|
@ -453,63 +479,109 @@ export const ComplianceAuditPage: React.FC = () => {
|
|||
},
|
||||
},
|
||||
{
|
||||
key: 'priceCHF', label: t('Kosten (CHF)'), type: 'number' as any, sortable: true, width: 110,
|
||||
formatter: (val: any) => val != null ? Number(val).toFixed(4) : '–',
|
||||
key: 'priceCHF',
|
||||
label: t('Kosten (CHF)'),
|
||||
sortable: true,
|
||||
width: 110,
|
||||
formatter: (val: any) => (val != null ? Number(val).toFixed(4) : '–'),
|
||||
},
|
||||
{
|
||||
key: 'neutralizationActive', label: t('Neutralisierung'), type: 'text' as any, sortable: true, width: 100,
|
||||
formatter: (val: any) => val ? '✓' : '–',
|
||||
key: 'neutralizationActive',
|
||||
label: t('Neutralisierung'),
|
||||
sortable: true,
|
||||
width: 100,
|
||||
formatter: (val: any) => (val ? '✓' : '–'),
|
||||
},
|
||||
{
|
||||
key: 'success', label: t('Status'), type: 'text' as any, sortable: true, filterable: true, width: 80,
|
||||
formatter: (val: any) => val ? t('OK') : t('Fehler'),
|
||||
cellClassName: (val: any) => val ? styles.statusOk : styles.statusError,
|
||||
key: 'success',
|
||||
label: t('Status'),
|
||||
sortable: true,
|
||||
filterable: true,
|
||||
width: 80,
|
||||
cellClassName: (val: any) => (val ? styles.statusOk : styles.statusError),
|
||||
},
|
||||
], [t]);
|
||||
|
||||
const auditLogColumns: ColumnConfig[] = useMemo(() => [
|
||||
{ key: 'timestamp', label: t('Zeitpunkt'), type: 'timestamp' as any, sortable: true, width: 160 },
|
||||
const aiLogColumns: ColumnConfig[] = useMemo(
|
||||
() => resolveColumnTypes(_rawAiLogColumns, aiAuditAttrs),
|
||||
[_rawAiLogColumns, aiAuditAttrs],
|
||||
);
|
||||
|
||||
const _rawAuditLogColumns: ColumnConfig[] = useMemo(() => [
|
||||
{ key: 'timestamp', label: t('Zeitpunkt'), sortable: true, width: 160 },
|
||||
{
|
||||
key: 'username', label: t('Benutzer'), type: 'text' as any, sortable: true, searchable: true, width: 140,
|
||||
formatter: (val: any, row: any) => val || (row?.userId ? row.userId.slice(0, 8) : '–'),
|
||||
key: 'username',
|
||||
label: t('Benutzer'),
|
||||
sortable: true,
|
||||
searchable: true,
|
||||
width: 140,
|
||||
formatter: (val: any, row: any) => val || (row?.userId ? `NA(${row.userId})` : '–'),
|
||||
},
|
||||
{
|
||||
key: 'category', label: t('Kategorie'), type: 'text' as any, sortable: true, filterable: true, width: 110,
|
||||
key: 'category',
|
||||
label: t('Kategorie'),
|
||||
sortable: true,
|
||||
filterable: true,
|
||||
width: 110,
|
||||
cellClassName: (val: any) => {
|
||||
const color = _CATEGORY_COLORS[val as string];
|
||||
return color ? styles[`cat_${val}`] || '' : '';
|
||||
},
|
||||
formatter: (val: any) => val || '–',
|
||||
},
|
||||
{ key: 'action', label: t('Aktion'), type: 'text' as any, sortable: true, filterable: true, searchable: true, width: 140 },
|
||||
{ key: 'resourceType', label: t('Ressource'), type: 'text' as any, sortable: true, filterable: true, width: 120 },
|
||||
{ key: 'details', label: t('Details'), type: 'text' as any, searchable: true, width: 250 },
|
||||
{ key: 'action', label: t('Aktion'), sortable: true, filterable: true, searchable: true, width: 140 },
|
||||
{ key: 'resourceType', label: t('Ressource'), sortable: true, filterable: true, width: 120 },
|
||||
{ key: 'details', label: t('Details'), searchable: true, width: 250 },
|
||||
{
|
||||
key: 'success', label: t('Status'), type: 'text' as any, sortable: true, width: 70,
|
||||
formatter: (val: any) => val ? '✓' : '✗',
|
||||
cellClassName: (val: any) => val ? styles.statusOk : styles.statusError,
|
||||
key: 'success',
|
||||
label: t('Status'),
|
||||
sortable: true,
|
||||
width: 70,
|
||||
formatter: (val: any) => (val ? '✓' : '✗'),
|
||||
cellClassName: (val: any) => (val ? styles.statusOk : styles.statusError),
|
||||
},
|
||||
{ key: 'ipAddress', label: t('IP'), type: 'text' as any, width: 120 },
|
||||
{ key: 'ipAddress', label: t('IP'), width: 120 },
|
||||
], [t]);
|
||||
|
||||
const neutColumns: ColumnConfig[] = useMemo(() => [
|
||||
{ key: 'placeholder', label: t('Platzhalter'), type: 'text' as any, sortable: true, searchable: true, width: 220 },
|
||||
{ key: 'originalText', label: t('Originaltext'), type: 'text' as any, sortable: true, searchable: true, width: 240 },
|
||||
{ key: 'patternType', label: t('Kategorie'), type: 'text' as any, sortable: true, filterable: true, width: 120 },
|
||||
const auditLogColumns: ColumnConfig[] = useMemo(
|
||||
() => resolveColumnTypes(_rawAuditLogColumns, auditLogAttrs),
|
||||
[_rawAuditLogColumns, auditLogAttrs],
|
||||
);
|
||||
|
||||
const _rawNeutColumns: ColumnConfig[] = useMemo(() => [
|
||||
{ key: 'placeholder', label: t('Platzhalter'), sortable: true, searchable: true, width: 220 },
|
||||
{ key: 'originalText', label: t('Originaltext'), sortable: true, searchable: true, width: 240 },
|
||||
{ key: 'patternType', label: t('Kategorie'), sortable: true, filterable: true, width: 120 },
|
||||
{
|
||||
key: 'username', label: t('Benutzer'), type: 'text' as any, sortable: true, filterable: true, width: 140,
|
||||
formatter: (val: any, row: any) => val || (row?.userId ? String(row.userId).slice(0, 8) + '…' : '–'),
|
||||
key: 'username',
|
||||
label: t('Benutzer'),
|
||||
sortable: true,
|
||||
filterable: true,
|
||||
width: 140,
|
||||
formatter: (val: any, row: any) => val || (row?.userId ? `NA(${row.userId})` : '–'),
|
||||
},
|
||||
{
|
||||
key: 'instanceLabel', label: t('Feature-Instanz'), type: 'text' as any, sortable: true, filterable: true, width: 160,
|
||||
formatter: (val: any, row: any) => val || (row?.featureInstanceId ? String(row.featureInstanceId).slice(0, 8) + '…' : '–'),
|
||||
key: 'instanceLabel',
|
||||
label: t('Feature-Instanz'),
|
||||
sortable: true,
|
||||
filterable: true,
|
||||
width: 160,
|
||||
formatter: (val: any, row: any) => val || (row?.featureInstanceId ? `NA(${row.featureInstanceId})` : '–'),
|
||||
},
|
||||
{
|
||||
key: 'fileId', label: t('Datei'), type: 'text' as any, sortable: true, width: 140,
|
||||
formatter: (val: any) => val ? String(val).slice(0, 8) + '…' : '–',
|
||||
key: 'fileId',
|
||||
label: t('Datei'),
|
||||
sortable: true,
|
||||
width: 140,
|
||||
formatter: (val: any) => (val ? `${String(val).slice(0, 8)}…` : '–'),
|
||||
},
|
||||
], [t]);
|
||||
|
||||
const neutColumns: ColumnConfig[] = useMemo(
|
||||
() => resolveColumnTypes(_rawNeutColumns, neutAttrs),
|
||||
[_rawNeutColumns, neutAttrs],
|
||||
);
|
||||
|
||||
// ── fetchFilterValues for autofilter dropdowns ──
|
||||
|
||||
const _makeFetchFilterValues = useCallback(
|
||||
|
|
|
|||
|
|
@ -375,12 +375,20 @@ const OrphansTab: React.FC = () => {
|
|||
const [downloading, setDownloading] = useState<string | null>(null);
|
||||
const [cleaningAll, setCleaningAll] = useState(false);
|
||||
const [onlyProblems, setOnlyProblems] = useState(true);
|
||||
// Default ON: deleted-user remnants belong to a dedicated purge workflow,
|
||||
// not to generic FK cleanup. Hiding them by default prevents confusion
|
||||
// (and accidental "Alle bereinigen" runs) when the SysAdmin scans for
|
||||
// genuine FK drift.
|
||||
const [excludeUserFks, setExcludeUserFks] = useState(true);
|
||||
const [dbFilter, setDbFilter] = useState<string>('');
|
||||
|
||||
const _fetchOrphans = useCallback(async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const params = dbFilter ? `?db=${encodeURIComponent(dbFilter)}` : '';
|
||||
const qs = new URLSearchParams();
|
||||
if (dbFilter) qs.set('db', dbFilter);
|
||||
if (excludeUserFks) qs.set('excludeUserFks', 'true');
|
||||
const params = qs.toString() ? `?${qs.toString()}` : '';
|
||||
const res = await api.get(`/api/admin/database-health/orphans${params}`);
|
||||
const rows = (res.data.orphans || []).map((o: any, i: number) => ({
|
||||
...o,
|
||||
|
|
@ -392,7 +400,7 @@ const OrphansTab: React.FC = () => {
|
|||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [dbFilter]);
|
||||
}, [dbFilter, excludeUserFks]);
|
||||
|
||||
useEffect(() => { _fetchOrphans(); }, [_fetchOrphans]);
|
||||
|
||||
|
|
@ -515,7 +523,7 @@ const OrphansTab: React.FC = () => {
|
|||
if (!ok) return;
|
||||
setCleaningAll(true);
|
||||
try {
|
||||
const res = await api.post('/api/admin/database-health/orphans/clean-all', { force });
|
||||
const res = await api.post('/api/admin/database-health/orphans/clean-all', { force, excludeUserFks });
|
||||
const results: CleanResult[] = res.data.results || [];
|
||||
const totalDeleted = results.reduce((s, r) => s + r.deleted, 0);
|
||||
const errors = results.filter(r => r.error);
|
||||
|
|
@ -636,6 +644,19 @@ const OrphansTab: React.FC = () => {
|
|||
{t('Nur Probleme')}
|
||||
</label>
|
||||
</div>
|
||||
<div className={styles.filterGroup}>
|
||||
<label
|
||||
className={styles.checkboxLabel}
|
||||
title={t('FK-Referenzen auf UserInDB.id ausblenden — diese werden über den User-Purge-Workflow separat behandelt.')}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={excludeUserFks}
|
||||
onChange={e => setExcludeUserFks(e.target.checked)}
|
||||
/>
|
||||
{t('Ohne FK-Referenzen zu UserInDB.id')}
|
||||
</label>
|
||||
</div>
|
||||
<div className={styles.headerActions}>
|
||||
<button className={styles.secondaryButton} onClick={_fetchOrphans} disabled={loading}>
|
||||
<FaSync className={loading ? 'spinning' : ''} /> {t('Scan')}
|
||||
|
|
|
|||
|
|
@ -156,3 +156,110 @@
|
|||
.spin {
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
.credentialsBox {
|
||||
margin-top: 0.75rem;
|
||||
padding: 0.75rem 0.85rem;
|
||||
border-radius: var(--object-radius-small, 6px);
|
||||
background: var(--bg-secondary, #fff);
|
||||
border: 1px dashed var(--border-color, #cbd5e1);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.credentialsBoxCompact {
|
||||
margin-top: 0.6rem;
|
||||
padding: 0.5rem 0.65rem;
|
||||
border-radius: var(--object-radius-small, 6px);
|
||||
background: var(--bg-tertiary, #f7f7f8);
|
||||
border: 1px dashed var(--border-color, #cbd5e1);
|
||||
color: var(--text-primary);
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
|
||||
:global(.dark-theme) .credentialsBox,
|
||||
:global(.dark-theme) .credentialsBoxCompact {
|
||||
background: var(--bg-tertiary, #2a2a3a);
|
||||
border-color: var(--border-color, #3d3d4d);
|
||||
}
|
||||
|
||||
.credentialsHeader {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
font-weight: 600;
|
||||
font-size: 0.78rem;
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: 0.4rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.03em;
|
||||
}
|
||||
|
||||
.credentialsRow {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.credentialsRow + .credentialsRow {
|
||||
margin-top: 0.5rem;
|
||||
padding-top: 0.5rem;
|
||||
border-top: 1px solid var(--border-color, #e2e8f0);
|
||||
}
|
||||
|
||||
.credentialsRole {
|
||||
font-size: 0.72rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
.credentialsField {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
|
||||
.credentialsField code {
|
||||
font-family: var(--font-mono, monospace);
|
||||
background: var(--bg-tertiary, #f7f7f8);
|
||||
padding: 1px 6px;
|
||||
border-radius: 4px;
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-primary);
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
:global(.dark-theme) .credentialsField code {
|
||||
background: var(--bg-secondary, #1e1e2e);
|
||||
}
|
||||
|
||||
.credentialsLabel {
|
||||
min-width: 60px;
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.copyButton {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
border: 1px solid var(--border-color, #cbd5e1);
|
||||
background: transparent;
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
font-size: 0.7rem;
|
||||
}
|
||||
|
||||
.copyButton:hover:not(:disabled) {
|
||||
background: var(--bg-secondary, #f7f7f8);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.copyButton:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,17 +6,25 @@
|
|||
*/
|
||||
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import { FaPlay, FaTrash, FaSync, FaCubes } from 'react-icons/fa';
|
||||
import { FaPlay, FaTrash, FaSync, FaCubes, FaCopy, FaKey } from 'react-icons/fa';
|
||||
import api from '../../api';
|
||||
import styles from './Admin.module.css';
|
||||
import demoStyles from './AdminDemoConfigPage.module.css';
|
||||
import { useLanguage } from '../../providers/language/LanguageContext';
|
||||
import { useConfirm } from '../../hooks/useConfirm';
|
||||
|
||||
interface _DemoCredential {
|
||||
role?: string;
|
||||
username?: string;
|
||||
email?: string;
|
||||
password?: string;
|
||||
}
|
||||
|
||||
interface _DemoConfig {
|
||||
code: string;
|
||||
label: string;
|
||||
description: string;
|
||||
credentials?: _DemoCredential[];
|
||||
}
|
||||
|
||||
interface _ActionResult {
|
||||
|
|
@ -24,6 +32,7 @@ interface _ActionResult {
|
|||
action: 'load' | 'remove';
|
||||
status: 'ok' | 'error';
|
||||
summary?: Record<string, unknown>;
|
||||
credentials?: _DemoCredential[];
|
||||
error?: string;
|
||||
}
|
||||
|
||||
|
|
@ -59,7 +68,18 @@ export const AdminDemoConfigPage: React.FC = () => {
|
|||
setLastResult(null);
|
||||
try {
|
||||
const response = await api.post(`/api/admin/demo-config/${code}/load`);
|
||||
setLastResult({ code, action: 'load', status: 'ok', summary: response.data.summary });
|
||||
const summary = (response.data?.summary || {}) as Record<string, unknown>;
|
||||
const credsFromSummary = Array.isArray(summary.credentials)
|
||||
? (summary.credentials as _DemoCredential[])
|
||||
: undefined;
|
||||
const credsFromConfig = configs.find((c) => c.code === code)?.credentials;
|
||||
setLastResult({
|
||||
code,
|
||||
action: 'load',
|
||||
status: 'ok',
|
||||
summary,
|
||||
credentials: credsFromSummary ?? credsFromConfig,
|
||||
});
|
||||
} catch (err: any) {
|
||||
setLastResult({ code, action: 'load', status: 'error', error: err.response?.data?.detail || String(err) });
|
||||
} finally {
|
||||
|
|
@ -110,6 +130,9 @@ export const AdminDemoConfigPage: React.FC = () => {
|
|||
) : (
|
||||
<span>{lastResult.error}</span>
|
||||
)}
|
||||
{lastResult.status === 'ok' && lastResult.action === 'load' && lastResult.credentials && lastResult.credentials.length > 0 && (
|
||||
<_CredentialsBox credentials={lastResult.credentials} />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
|
@ -126,6 +149,9 @@ export const AdminDemoConfigPage: React.FC = () => {
|
|||
<h3 className={demoStyles.cardTitle}>{cfg.label}</h3>
|
||||
<p className={demoStyles.cardDescription}>{cfg.description}</p>
|
||||
<span className={demoStyles.cardCode}>{cfg.code}</span>
|
||||
{cfg.credentials && cfg.credentials.length > 0 && (
|
||||
<_CredentialsBox credentials={cfg.credentials} compact />
|
||||
)}
|
||||
</div>
|
||||
<div className={demoStyles.cardActions}>
|
||||
<button
|
||||
|
|
@ -158,7 +184,10 @@ export const AdminDemoConfigPage: React.FC = () => {
|
|||
const _SummaryDisplay: React.FC<{ summary?: Record<string, unknown> }> = ({ summary }) => {
|
||||
const { t } = useLanguage();
|
||||
if (!summary) return null;
|
||||
const sections = Object.entries(summary).filter(([, v]) => Array.isArray(v) && (v as unknown[]).length > 0);
|
||||
// Skip the credentials block here -- it gets its own copyable widget below.
|
||||
const sections = Object.entries(summary)
|
||||
.filter(([key]) => key !== 'credentials')
|
||||
.filter(([, v]) => Array.isArray(v) && (v as unknown[]).length > 0);
|
||||
if (sections.length === 0) return <span>{t('Abgeschlossen (keine Änderungen)')}</span>;
|
||||
return (
|
||||
<span>
|
||||
|
|
@ -170,3 +199,73 @@ const _SummaryDisplay: React.FC<{ summary?: Record<string, unknown> }> = ({ summ
|
|||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
const _CredentialsBox: React.FC<{ credentials: _DemoCredential[]; compact?: boolean }> = ({ credentials, compact }) => {
|
||||
const { t } = useLanguage();
|
||||
const [copiedKey, setCopiedKey] = useState<string | null>(null);
|
||||
|
||||
const _copy = async (key: string, value: string) => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(value);
|
||||
setCopiedKey(key);
|
||||
window.setTimeout(() => setCopiedKey((prev) => (prev === key ? null : prev)), 1500);
|
||||
} catch {
|
||||
// ignore clipboard failures (no permission, http, ...)
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={compact ? demoStyles.credentialsBoxCompact : demoStyles.credentialsBox}>
|
||||
<div className={demoStyles.credentialsHeader}>
|
||||
<FaKey />
|
||||
<span>{t('Login-Daten')}</span>
|
||||
</div>
|
||||
{credentials.map((cred, idx) => {
|
||||
// Login uses the USERNAME (the email is just informational metadata
|
||||
// about the demo user -- the auth flow keys off `username`).
|
||||
const loginValue = cred.username || '';
|
||||
const pwd = cred.password || '';
|
||||
const rowKey = `${idx}-${loginValue}`;
|
||||
return (
|
||||
<div key={rowKey} className={demoStyles.credentialsRow}>
|
||||
{cred.role && <div className={demoStyles.credentialsRole}>{cred.role}</div>}
|
||||
<div className={demoStyles.credentialsField}>
|
||||
<span className={demoStyles.credentialsLabel}>{t('Login')}:</span>
|
||||
<code>{loginValue}</code>
|
||||
<button
|
||||
type="button"
|
||||
className={demoStyles.copyButton}
|
||||
onClick={() => _copy(`${rowKey}-login`, loginValue)}
|
||||
disabled={!loginValue}
|
||||
title={t('Login kopieren')}
|
||||
>
|
||||
<FaCopy />
|
||||
{copiedKey === `${rowKey}-login` ? ` ${t('kopiert')}` : ''}
|
||||
</button>
|
||||
</div>
|
||||
<div className={demoStyles.credentialsField}>
|
||||
<span className={demoStyles.credentialsLabel}>{t('Passwort')}:</span>
|
||||
<code>{pwd}</code>
|
||||
<button
|
||||
type="button"
|
||||
className={demoStyles.copyButton}
|
||||
onClick={() => _copy(`${rowKey}-pwd`, pwd)}
|
||||
disabled={!pwd}
|
||||
title={t('Passwort kopieren')}
|
||||
>
|
||||
<FaCopy />
|
||||
{copiedKey === `${rowKey}-pwd` ? ` ${t('kopiert')}` : ''}
|
||||
</button>
|
||||
</div>
|
||||
{cred.email && (
|
||||
<div className={demoStyles.credentialsField}>
|
||||
<span className={demoStyles.credentialsLabel}>{t('E-Mail')}:</span>
|
||||
<code>{cred.email}</code>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -14,6 +14,10 @@ import { FormGeneratorForm, type AttributeDefinition } from '../../components/Fo
|
|||
import { FaPlus, FaSync, FaCube, FaBuilding, FaCogs, FaEdit } from 'react-icons/fa';
|
||||
import { useToast } from '../../contexts/ToastContext';
|
||||
import api from '../../api';
|
||||
import { useApiRequest } from '../../hooks/useApi';
|
||||
import { fetchAttributes } from '../../api/attributesApi';
|
||||
import { resolveColumnTypes } from '../../utils/columnTypeResolver';
|
||||
import type { ColumnConfig } from '../../components/FormGenerator/FormGeneratorTable';
|
||||
import { ChatbotConfigSection } from './ChatbotConfigSection';
|
||||
import { TextField } from '../../components/UiComponents/TextField';
|
||||
import styles from './Admin.module.css';
|
||||
|
|
@ -42,6 +46,7 @@ export const AdminFeatureAccessPage: React.FC = () => {
|
|||
const { fetchMandates } = useUserMandates();
|
||||
const { showSuccess, showError } = useToast();
|
||||
const { loadFeatures } = useFeatureStore();
|
||||
const { request } = useApiRequest();
|
||||
|
||||
// State
|
||||
const [mandates, setMandates] = useState<Mandate[]>([]);
|
||||
|
|
@ -88,18 +93,28 @@ export const AdminFeatureAccessPage: React.FC = () => {
|
|||
}
|
||||
}, [selectedMandateId, fetchInstances]);
|
||||
|
||||
// Table columns
|
||||
const columns = useMemo(() => [
|
||||
{ key: 'label', label: t('Name'), type: 'string' as const, sortable: true, filterable: true, searchable: true, width: 200 },
|
||||
{ key: 'featureCode', label: t('Feature'), type: 'string' as const, sortable: true, filterable: true, width: 150,
|
||||
render: (value: string) => {
|
||||
const _rawColumns: ColumnConfig[] = useMemo(() => [
|
||||
{ key: 'label', label: t('Name'), sortable: true, filterable: true, searchable: true, width: 200 },
|
||||
{
|
||||
key: 'featureCode',
|
||||
label: t('Feature'),
|
||||
sortable: true,
|
||||
filterable: true,
|
||||
width: 150,
|
||||
formatter: (value: string) => {
|
||||
const feature = features.find(f => f.code === value);
|
||||
return feature ? (feature.label || value) : value;
|
||||
}
|
||||
const label = feature ? (feature.label || value) : value;
|
||||
return label;
|
||||
},
|
||||
},
|
||||
{ key: 'enabled', label: t('Aktiv'), type: 'boolean' as const, sortable: true, filterable: true, width: 80 },
|
||||
{ key: 'enabled', label: t('Aktiv'), sortable: true, filterable: true, width: 80 },
|
||||
], [features, t]);
|
||||
|
||||
const columns = useMemo(
|
||||
() => resolveColumnTypes(_rawColumns, backendAttributes),
|
||||
[_rawColumns, backendAttributes],
|
||||
);
|
||||
|
||||
// Form attributes from backend - merge with dynamic feature options
|
||||
// Exclude featureCode, config, and label since we handle them separately
|
||||
const createFields: AttributeDefinition[] = useMemo(() => {
|
||||
|
|
|
|||
|
|
@ -14,6 +14,10 @@ import { FaPlus, FaSync, FaBuilding, FaCube } from 'react-icons/fa';
|
|||
import { useToast } from '../../contexts/ToastContext';
|
||||
import { useFeatureStore } from '../../stores/featureStore';
|
||||
import api from '../../api';
|
||||
import { useApiRequest } from '../../hooks/useApi';
|
||||
import { fetchAttributes } from '../../api/attributesApi';
|
||||
import { resolveColumnTypes } from '../../utils/columnTypeResolver';
|
||||
import type { ColumnConfig } from '../../components/FormGenerator/FormGeneratorTable';
|
||||
import styles from './Admin.module.css';
|
||||
|
||||
import { useLanguage } from '../../providers/language/LanguageContext';
|
||||
|
|
@ -38,6 +42,8 @@ export const AdminFeatureInstanceUsersPage: React.FC = () => {
|
|||
const { fetchMandates } = useUserMandates();
|
||||
const { showSuccess, showError } = useToast();
|
||||
const { loadFeatures } = useFeatureStore();
|
||||
const { request } = useApiRequest();
|
||||
const [backendAttributes, setBackendAttributes] = useState<AttributeDefinition[]>([]);
|
||||
|
||||
// Combined instance option type
|
||||
interface CombinedInstanceOption {
|
||||
|
|
@ -72,6 +78,12 @@ export const AdminFeatureInstanceUsersPage: React.FC = () => {
|
|||
return selectedCombinedKey.split(':')[1] || '';
|
||||
}, [selectedCombinedKey]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchAttributes(request, 'FeatureAccessView')
|
||||
.then(setBackendAttributes)
|
||||
.catch(() => setBackendAttributes([]));
|
||||
}, [request]);
|
||||
|
||||
// Load mandates and features on mount, then build combined options
|
||||
useEffect(() => {
|
||||
fetchFeatures();
|
||||
|
|
@ -199,12 +211,10 @@ export const AdminFeatureInstanceUsersPage: React.FC = () => {
|
|||
return allUsers.filter(u => !existingUserIds.has(u.id));
|
||||
}, [allUsers, instanceUsers]);
|
||||
|
||||
// Table columns
|
||||
const columns = useMemo(() => [
|
||||
const _rawColumns: ColumnConfig[] = useMemo(() => [
|
||||
{
|
||||
key: 'username',
|
||||
label: t('Benutzername'),
|
||||
type: 'text' as const,
|
||||
sortable: true,
|
||||
filterable: true,
|
||||
searchable: true,
|
||||
|
|
@ -213,7 +223,6 @@ export const AdminFeatureInstanceUsersPage: React.FC = () => {
|
|||
{
|
||||
key: 'email',
|
||||
label: t('E-Mail'),
|
||||
type: 'text' as const,
|
||||
sortable: true,
|
||||
filterable: true,
|
||||
searchable: true,
|
||||
|
|
@ -222,7 +231,6 @@ export const AdminFeatureInstanceUsersPage: React.FC = () => {
|
|||
{
|
||||
key: 'fullName',
|
||||
label: t('Vollständiger Name'),
|
||||
type: 'text' as const,
|
||||
sortable: true,
|
||||
filterable: true,
|
||||
searchable: true,
|
||||
|
|
@ -231,12 +239,11 @@ export const AdminFeatureInstanceUsersPage: React.FC = () => {
|
|||
{
|
||||
key: 'roleLabels',
|
||||
label: t('Rollen'),
|
||||
type: 'text' as const,
|
||||
sortable: false,
|
||||
filterable: false,
|
||||
searchable: true,
|
||||
width: 200,
|
||||
render: (value: string[]) => {
|
||||
formatter: (value: string[]) => {
|
||||
if (!value || value.length === 0) return '-';
|
||||
return value.join(', ');
|
||||
},
|
||||
|
|
@ -244,7 +251,6 @@ export const AdminFeatureInstanceUsersPage: React.FC = () => {
|
|||
{
|
||||
key: 'enabled',
|
||||
label: t('Aktiv'),
|
||||
type: 'boolean' as const,
|
||||
sortable: true,
|
||||
filterable: true,
|
||||
searchable: false,
|
||||
|
|
@ -252,6 +258,11 @@ export const AdminFeatureInstanceUsersPage: React.FC = () => {
|
|||
},
|
||||
], [t]);
|
||||
|
||||
const columns = useMemo(
|
||||
() => resolveColumnTypes(_rawColumns, backendAttributes),
|
||||
[_rawColumns, backendAttributes],
|
||||
);
|
||||
|
||||
// Dynamic options for forms (users and roles)
|
||||
const userOptions = useMemo(() =>
|
||||
availableUsers.map(u => ({
|
||||
|
|
|
|||
|
|
@ -17,6 +17,10 @@ import { AccessRulesEditor } from '../../components/AccessRules';
|
|||
import { FaPlus, FaSync, FaUserShield, FaCube, FaShieldAlt } from 'react-icons/fa';
|
||||
import { useToast } from '../../contexts/ToastContext';
|
||||
import api from '../../api';
|
||||
import { useApiRequest } from '../../hooks/useApi';
|
||||
import { fetchAttributes } from '../../api/attributesApi';
|
||||
import { resolveColumnTypes } from '../../utils/columnTypeResolver';
|
||||
import type { ColumnConfig } from '../../components/FormGenerator/FormGeneratorTable';
|
||||
import styles from './Admin.module.css';
|
||||
|
||||
import { useLanguage } from '../../providers/language/LanguageContext';
|
||||
|
|
@ -45,6 +49,9 @@ export const AdminFeatureRolesPage: React.FC = () => {
|
|||
const { t } = useLanguage();
|
||||
|
||||
const { showError } = useToast();
|
||||
const { request } = useApiRequest();
|
||||
const [roleTableAttributes, setRoleTableAttributes] = useState<AttributeDefinition[]>([]);
|
||||
|
||||
// State
|
||||
const [features, setFeatures] = useState<Feature[]>([]);
|
||||
const [selectedFeatureCode, setSelectedFeatureCode] = useState<string>('');
|
||||
|
|
@ -56,6 +63,12 @@ export const AdminFeatureRolesPage: React.FC = () => {
|
|||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [permissionsRole, setPermissionsRole] = useState<FeatureRole | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
fetchAttributes(request, 'Role')
|
||||
.then(setRoleTableAttributes)
|
||||
.catch(() => setRoleTableAttributes([]));
|
||||
}, [request]);
|
||||
|
||||
// Load features on mount
|
||||
useEffect(() => {
|
||||
const loadFeatures = async () => {
|
||||
|
|
@ -130,29 +143,25 @@ export const AdminFeatureRolesPage: React.FC = () => {
|
|||
return String(value);
|
||||
};
|
||||
|
||||
// Table columns
|
||||
const columns = useMemo(() => [
|
||||
const _rawColumns: ColumnConfig[] = useMemo(() => [
|
||||
{
|
||||
key: 'roleLabel',
|
||||
label: t('Rollen-Label'),
|
||||
type: 'string' as const,
|
||||
sortable: true,
|
||||
filterable: true,
|
||||
searchable: true,
|
||||
width: 180
|
||||
width: 180,
|
||||
},
|
||||
{
|
||||
key: 'description',
|
||||
label: t('Beschreibung'),
|
||||
type: 'string' as const,
|
||||
sortable: false,
|
||||
width: 300,
|
||||
formatter: (value: string) => getTextValue(value)
|
||||
formatter: (value: string) => getTextValue(value),
|
||||
},
|
||||
{
|
||||
key: 'featureCode',
|
||||
label: t('Feature'),
|
||||
type: 'string' as const,
|
||||
sortable: true,
|
||||
filterable: true,
|
||||
width: 120,
|
||||
|
|
@ -160,10 +169,15 @@ export const AdminFeatureRolesPage: React.FC = () => {
|
|||
<span className={styles.badge} style={{ background: 'var(--primary-color, #4a5568)', color: 'white' }}>
|
||||
<FaCube style={{ marginRight: 4 }} /> {value}
|
||||
</span>
|
||||
)
|
||||
),
|
||||
},
|
||||
], [t]);
|
||||
|
||||
const columns = useMemo(
|
||||
() => resolveColumnTypes(_rawColumns, roleTableAttributes),
|
||||
[_rawColumns, roleTableAttributes],
|
||||
);
|
||||
|
||||
// Form attributes for create
|
||||
const createFields: AttributeDefinition[] = useMemo(() => {
|
||||
const fields: AttributeDefinition[] = [
|
||||
|
|
|
|||
|
|
@ -12,7 +12,10 @@ import { FormGeneratorTable } from '../../components/FormGenerator/FormGenerator
|
|||
import { FormGeneratorForm, type AttributeDefinition } from '../../components/FormGenerator/FormGeneratorForm';
|
||||
import { FaPlus, FaSync, FaBuilding, FaCopy, FaLink } from 'react-icons/fa';
|
||||
import { useToast } from '../../contexts/ToastContext';
|
||||
import api from '../../api';
|
||||
import { useApiRequest } from '../../hooks/useApi';
|
||||
import { fetchAttributes } from '../../api/attributesApi';
|
||||
import { resolveColumnTypes } from '../../utils/columnTypeResolver';
|
||||
import type { ColumnConfig } from '../../components/FormGenerator/FormGeneratorTable';
|
||||
import styles from './Admin.module.css';
|
||||
|
||||
import { useLanguage } from '../../providers/language/LanguageContext';
|
||||
|
|
@ -22,6 +25,7 @@ export const AdminInvitationsPage: React.FC = () => {
|
|||
const { t } = useLanguage();
|
||||
|
||||
const { showError } = useToast();
|
||||
const { request } = useApiRequest();
|
||||
const {
|
||||
invitations,
|
||||
loading,
|
||||
|
|
@ -56,12 +60,10 @@ export const AdminInvitationsPage: React.FC = () => {
|
|||
}
|
||||
};
|
||||
loadMandates();
|
||||
// Fetch Invitation attributes from backend
|
||||
api.get('/api/attributes/Invitation').then(response => {
|
||||
const attrs = response.data?.attributes || response.data || [];
|
||||
setBackendAttributes(Array.isArray(attrs) ? attrs : []);
|
||||
}).catch(() => setBackendAttributes([]));
|
||||
}, [fetchMandates]);
|
||||
fetchAttributes(request, 'Invitation')
|
||||
.then(setBackendAttributes)
|
||||
.catch(() => setBackendAttributes([]));
|
||||
}, [fetchMandates, request]);
|
||||
|
||||
// Load invitations and roles when mandate changes (same roles as AdminUserMandatesPage: user, viewer, admin)
|
||||
useEffect(() => {
|
||||
|
|
@ -71,7 +73,7 @@ export const AdminInvitationsPage: React.FC = () => {
|
|||
}
|
||||
}, [selectedMandateId, showExpired, showUsed, fetchInvitations, fetchRoles]);
|
||||
|
||||
// Format timestamp
|
||||
// Format timestamp (used by URL modal only).
|
||||
const formatDate = (timestamp: number) => {
|
||||
if (!timestamp) return '-';
|
||||
const date = new Date(timestamp * 1000);
|
||||
|
|
@ -84,86 +86,45 @@ export const AdminInvitationsPage: React.FC = () => {
|
|||
});
|
||||
};
|
||||
|
||||
// Table columns
|
||||
const columns = useMemo(() => [
|
||||
{
|
||||
key: 'targetUsername',
|
||||
label: t('Benutzername'),
|
||||
type: 'string' as const,
|
||||
sortable: true,
|
||||
filterable: true,
|
||||
searchable: true,
|
||||
width: 150,
|
||||
},
|
||||
{
|
||||
key: 'email',
|
||||
label: t('E-Mail'),
|
||||
type: 'string' as const,
|
||||
sortable: true,
|
||||
filterable: true,
|
||||
width: 180,
|
||||
render: (value: string, row: Invitation) => {
|
||||
const emailText = value || '-';
|
||||
const emailSent = (row as any).emailSent;
|
||||
return (
|
||||
<span title={emailSent ? t('E-Mail wurde gesendet') : t('E-Mail nicht gesendet')}>
|
||||
{emailText} {emailSent && '✓'}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
},
|
||||
const _rawColumns: ColumnConfig[] = useMemo(() => [
|
||||
{ key: 'targetUsername', sortable: true, filterable: true, searchable: true, width: 150 },
|
||||
{ key: 'email', sortable: true, filterable: true, width: 180 },
|
||||
{ key: 'emailSentFlag', sortable: true, filterable: true, width: 90 },
|
||||
{ key: 'emailSentAt', sortable: true, filterable: true, width: 150 },
|
||||
{
|
||||
key: 'roleIds',
|
||||
label: t('Rollen'),
|
||||
type: 'string', // Array rendered as string
|
||||
sortable: false,
|
||||
filterable: false,
|
||||
width: 150,
|
||||
render: (value: string[]) => {
|
||||
formatter: (value: string[]) => {
|
||||
if (!value || value.length === 0) return '-';
|
||||
return value.map(roleId => {
|
||||
return value.map((roleId) => {
|
||||
const role = roles.find(r => r.id === roleId);
|
||||
return role?.roleLabel || roleId;
|
||||
}).join(', ');
|
||||
}
|
||||
} as any,
|
||||
{
|
||||
key: 'expiresAt',
|
||||
label: t('Gültig bis'),
|
||||
type: 'number' as const,
|
||||
sortable: true,
|
||||
width: 150,
|
||||
render: (value: number) => {
|
||||
const text = formatDate(value);
|
||||
const isExpired = value < Date.now() / 1000;
|
||||
return (
|
||||
<span style={{ color: isExpired ? 'var(--danger-color)' : 'inherit' }}>
|
||||
{text} {isExpired && '(abgelaufen)'}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
},
|
||||
},
|
||||
{ key: 'expiresAt', sortable: true, filterable: true, width: 150 },
|
||||
{ key: 'expiredFlag', sortable: true, filterable: true, width: 90 },
|
||||
{
|
||||
key: 'currentUses',
|
||||
label: t('Verwendet'),
|
||||
type: 'string' as const,
|
||||
sortable: true,
|
||||
filterable: true,
|
||||
width: 100,
|
||||
render: (value: number, row: Invitation) => `${value || 0} / ${row.maxUses || 1}`
|
||||
formatter: (value: number, row: Invitation) => `${value || 0} / ${row.maxUses || 1}`,
|
||||
},
|
||||
{
|
||||
key: 'createdAt',
|
||||
label: t('Erstellt'),
|
||||
type: 'number' as const,
|
||||
sortable: true,
|
||||
width: 150,
|
||||
render: (value: number) => formatDate(value)
|
||||
},
|
||||
], [roles, t]);
|
||||
{ key: 'usedUpFlag', sortable: true, filterable: true, width: 90 },
|
||||
{ key: 'sysCreatedAt', sortable: true, filterable: true, width: 150 },
|
||||
], [roles]);
|
||||
|
||||
const columns = useMemo(
|
||||
() => resolveColumnTypes(_rawColumns, backendAttributes),
|
||||
[_rawColumns, backendAttributes],
|
||||
);
|
||||
|
||||
// Form attributes - same role options as AdminUserMandatesPage (user, viewer, admin)
|
||||
const createFields: AttributeDefinition[] = useMemo(() => {
|
||||
const excludedFields = ['id', 'mandateId', 'token', 'createdBy', 'createdAt', 'expiresAt', 'currentUses', 'inviteUrl', 'featureInstanceId'];
|
||||
const excludedFields = ['id', 'mandateId', 'token', 'sysCreatedBy', 'sysCreatedAt', 'sysUpdatedAt', 'sysUpdatedBy', 'expiresAt', 'currentUses', 'inviteUrl', 'featureInstanceId'];
|
||||
|
||||
// Mandate-level roles (user, viewer, admin) - same as when adding mandate members
|
||||
const roleOptions = roles
|
||||
|
|
@ -445,8 +406,8 @@ export const AdminInvitationsPage: React.FC = () => {
|
|||
{t('verwendet werden.')}
|
||||
</p>
|
||||
{showUrlModal.email && (
|
||||
<p style={{ marginTop: '0.5rem', fontSize: '0.875rem', color: showUrlModal.emailSent ? 'var(--success-color)' : 'var(--text-secondary)' }}>
|
||||
{showUrlModal.emailSent
|
||||
<p style={{ marginTop: '0.5rem', fontSize: '0.875rem', color: showUrlModal.emailSentFlag ? 'var(--success-color)' : 'var(--text-secondary)' }}>
|
||||
{showUrlModal.emailSentFlag
|
||||
? `✓ ${t('E-Mail wurde an {email} gesendet', { email: showUrlModal.email })}`
|
||||
: `${t('E-Mail-Adresse')}: ${showUrlModal.email} (${t('nicht gesendet')})`}
|
||||
</p>
|
||||
|
|
|
|||
|
|
@ -8,6 +8,10 @@ import api from '../../api';
|
|||
import axios from 'axios';
|
||||
import { FormGeneratorTable, type ColumnConfig } from '../../components/FormGenerator/FormGeneratorTable/FormGeneratorTable';
|
||||
import { useConfirm } from '../../hooks/useConfirm';
|
||||
import { useApiRequest } from '../../hooks/useApi';
|
||||
import { fetchAttributes } from '../../api/attributesApi';
|
||||
import type { AttributeDefinition } from '../../api/attributesApi';
|
||||
import { resolveColumnTypes } from '../../utils/columnTypeResolver';
|
||||
import { useLanguage } from '../../providers/language/LanguageContext';
|
||||
import styles from './Admin.module.css';
|
||||
|
||||
|
|
@ -39,45 +43,13 @@ type ProgressInfo = {
|
|||
keysTranslated?: number;
|
||||
};
|
||||
|
||||
function _getColumns(t: (key: string) => string): ColumnConfig[] {
|
||||
return [
|
||||
{ key: 'id', label: t('Code'), type: 'text', sortable: true, filterable: true, width: 90 },
|
||||
{ key: 'label', label: t('Bezeichnung'), type: 'text', sortable: true, filterable: true, width: 200 },
|
||||
{
|
||||
key: 'status',
|
||||
label: t('Status'),
|
||||
type: 'text',
|
||||
sortable: true,
|
||||
filterable: true,
|
||||
width: 160,
|
||||
formatter: (_val: any, row: any) => {
|
||||
const r = row as LangRow;
|
||||
if (r.updating) {
|
||||
return (
|
||||
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 6, color: 'var(--color-warning, #e6a700)' }}>
|
||||
<FaSync style={{ animation: 'spin 1s linear infinite', fontSize: '0.85em' }} />
|
||||
{t('wird aktualisiert…')}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
if (r.status === 'generating') {
|
||||
return (
|
||||
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 6, color: 'var(--color-warning, #e6a700)' }}>
|
||||
<FaSync style={{ animation: 'spin 1s linear infinite', fontSize: '0.85em' }} />
|
||||
{t('wird erzeugt…')}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
return r.status;
|
||||
},
|
||||
},
|
||||
{ key: 'uiCount', label: t('UI'), type: 'number', sortable: true, width: 80 },
|
||||
{ key: 'gatewayCount', label: t('API'), type: 'number', sortable: true, width: 80 },
|
||||
{ key: 'entriesCount', label: t('Gesamt'), type: 'number', sortable: true, width: 80 },
|
||||
];
|
||||
}
|
||||
|
||||
const _PRIORITY_CODES = ['de', 'gsw', 'en', 'fr', 'it'];
|
||||
// ISO 639 catalog (codes + native labels + priority order) is provided by the
|
||||
// gateway via GET /api/i18n/iso-choices. We must NOT keep a local copy here --
|
||||
// any divergence between frontend and backend caused subtle bugs (e.g. user
|
||||
// could create a language code that the AI translation prompt did not know how
|
||||
// to label). The catalog is fetched once on mount and held in component state.
|
||||
type IsoChoice = { value: string; label: string };
|
||||
type IsoCatalogResponse = { priorityCodes: string[]; choices: IsoChoice[] };
|
||||
|
||||
function _isAbortError(e: unknown): boolean {
|
||||
if (axios.isCancel(e)) return true;
|
||||
|
|
@ -88,55 +60,6 @@ function _isAbortError(e: unknown): boolean {
|
|||
return false;
|
||||
}
|
||||
|
||||
const _isoChoices: { value: string; label: string }[] = [
|
||||
{ value: 'de', label: 'de — Deutsch' },
|
||||
{ value: 'gsw', label: 'gsw — Schweizerdeutsch' },
|
||||
{ value: 'en', label: 'en — English' },
|
||||
{ value: 'fr', label: 'fr — Français' }, { value: 'it', label: 'it — Italiano' },
|
||||
{ value: 'es', label: 'es — Español' }, { value: 'pt', label: 'pt — Português' },
|
||||
{ value: 'nl', label: 'nl — Nederlands' }, { value: 'pl', label: 'pl — Polski' },
|
||||
{ value: 'cs', label: 'cs — Čeština' }, { value: 'sk', label: 'sk — Slovenčina' },
|
||||
{ value: 'sv', label: 'sv — Svenska' }, { value: 'no', label: 'no — Norsk' },
|
||||
{ value: 'da', label: 'da — Dansk' }, { value: 'fi', label: 'fi — Suomi' },
|
||||
{ value: 'hu', label: 'hu — Magyar' }, { value: 'ro', label: 'ro — Română' },
|
||||
{ value: 'bg', label: 'bg — Български' }, { value: 'hr', label: 'hr — Hrvatski' },
|
||||
{ value: 'sl', label: 'sl — Slovenščina' }, { value: 'et', label: 'et — Eesti' },
|
||||
{ value: 'lv', label: 'lv — Latviešu' }, { value: 'lt', label: 'lt — Lietuvių' },
|
||||
{ value: 'el', label: 'el — Ελληνικά' }, { value: 'tr', label: 'tr — Türkçe' },
|
||||
{ value: 'ru', label: 'ru — Русский' }, { value: 'uk', label: 'uk — Українська' },
|
||||
{ value: 'ar', label: 'ar — العربية' }, { value: 'he', label: 'he — עברית' },
|
||||
{ value: 'zh', label: 'zh — 中文' }, { value: 'ja', label: 'ja — 日本語' },
|
||||
{ value: 'ko', label: 'ko — 한국어' }, { value: 'hi', label: 'hi — हिन्दी' },
|
||||
{ value: 'th', label: 'th — ไทย' }, { value: 'vi', label: 'vi — Tiếng Việt' },
|
||||
{ value: 'id', label: 'id — Bahasa Indonesia' }, { value: 'ms', label: 'ms — Bahasa Melayu' },
|
||||
{ value: 'tl', label: 'tl — Filipino' }, { value: 'sw', label: 'sw — Kiswahili' },
|
||||
{ value: 'af', label: 'af — Afrikaans' }, { value: 'sq', label: 'sq — Shqip' },
|
||||
{ value: 'am', label: 'am — አማርኛ' }, { value: 'hy', label: 'hy — Հայերեն' },
|
||||
{ value: 'az', label: 'az — Azərbaycan' }, { value: 'eu', label: 'eu — Euskara' },
|
||||
{ value: 'be', label: 'be — Беларуская' }, { value: 'bn', label: 'bn — বাংলা' },
|
||||
{ value: 'bs', label: 'bs — Bosanski' }, { value: 'ca', label: 'ca — Català' },
|
||||
{ value: 'cy', label: 'cy — Cymraeg' }, { value: 'eo', label: 'eo — Esperanto' },
|
||||
{ value: 'fa', label: 'fa — فارسی' }, { value: 'ga', label: 'ga — Gaeilge' },
|
||||
{ value: 'gl', label: 'gl — Galego' }, { value: 'gu', label: 'gu — ગુજરાતી' },
|
||||
{ value: 'ha', label: 'ha — Hausa' }, { value: 'is', label: 'is — Íslenska' },
|
||||
{ value: 'jv', label: 'jv — Basa Jawa' }, { value: 'ka', label: 'ka — ქართული' },
|
||||
{ value: 'kk', label: 'kk — Қазақ' }, { value: 'km', label: 'km — ខ្មែរ' },
|
||||
{ value: 'kn', label: 'kn — ಕನ್ನಡ' }, { value: 'ku', label: 'ku — Kurdî' },
|
||||
{ value: 'ky', label: 'ky — Кыргызча' }, { value: 'la', label: 'la — Latina' },
|
||||
{ value: 'lb', label: 'lb — Lëtzebuergesch' }, { value: 'lo', label: 'lo — ລາວ' },
|
||||
{ value: 'mk', label: 'mk — Македонски' }, { value: 'ml', label: 'ml — മലയാളം' },
|
||||
{ value: 'mn', label: 'mn — Монгол' }, { value: 'mr', label: 'mr — मराठी' },
|
||||
{ value: 'mt', label: 'mt — Malti' }, { value: 'my', label: 'my — မြန်မာ' },
|
||||
{ value: 'ne', label: 'ne — नेपाली' }, { value: 'or', label: 'or — ଓଡ଼ିଆ' },
|
||||
{ value: 'pa', label: 'pa — ਪੰਜਾਬੀ' }, { value: 'ps', label: 'ps — پښتو' },
|
||||
{ value: 'si', label: 'si — සිංහල' }, { value: 'so', label: 'so — Soomaali' },
|
||||
{ value: 'sr', label: 'sr — Српски' }, { value: 'su', label: 'su — Basa Sunda' },
|
||||
{ value: 'ta', label: 'ta — தமிழ்' }, { value: 'te', label: 'te — తెలుగు' },
|
||||
{ value: 'tg', label: 'tg — Тоҷикӣ' }, { value: 'tk', label: 'tk — Türkmen' },
|
||||
{ value: 'ur', label: 'ur — اردو' }, { value: 'uz', label: 'uz — Oʻzbek' },
|
||||
{ value: 'yo', label: 'yo — Yorùbá' }, { value: 'zu', label: 'zu — isiZulu' },
|
||||
];
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Progress overlay component
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
@ -321,15 +244,44 @@ const _ProgressOverlay: React.FC<{
|
|||
export const AdminLanguagesPage: React.FC = () => {
|
||||
const { t, reloadLanguage, refreshAvailableLanguages } = useLanguage();
|
||||
const { confirm, ConfirmDialog } = useConfirm();
|
||||
const { request } = useApiRequest();
|
||||
const [langSetAttributes, setLangSetAttributes] = useState<AttributeDefinition[]>([]);
|
||||
const [rows, setRows] = useState<LangRow[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [addCode, setAddCode] = useState('');
|
||||
const [progress, setProgress] = useState<ProgressInfo | null>(null);
|
||||
const [search, setSearch] = useState('');
|
||||
const [isoCatalog, setIsoCatalog] = useState<IsoCatalogResponse>({ priorityCodes: [], choices: [] });
|
||||
const busyRef = useRef(false);
|
||||
const abortRef = useRef<AbortController | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
fetchAttributes(request, 'UiLanguageSetView')
|
||||
.then(setLangSetAttributes)
|
||||
.catch(() => setLangSetAttributes([]));
|
||||
}, [request]);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
try {
|
||||
const res = await api.get('/api/i18n/iso-choices');
|
||||
if (cancelled) return;
|
||||
const data = res.data as IsoCatalogResponse;
|
||||
setIsoCatalog({
|
||||
priorityCodes: Array.isArray(data?.priorityCodes) ? data.priorityCodes : [],
|
||||
choices: Array.isArray(data?.choices) ? data.choices : [],
|
||||
});
|
||||
} catch (e) {
|
||||
console.error('Failed to load ISO language catalog from /api/i18n/iso-choices:', e);
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const _endProgressSoon = useCallback((ms: number) => {
|
||||
window.setTimeout(() => {
|
||||
setProgress(null);
|
||||
|
|
@ -415,20 +367,98 @@ export const AdminLanguagesPage: React.FC = () => {
|
|||
});
|
||||
}, [rows, search]);
|
||||
|
||||
const _fetchFilterValues = useCallback(
|
||||
async (columnKey: string, crossFilters?: Record<string, any>): Promise<(string | null)[]> => {
|
||||
let source = displayRows;
|
||||
if (crossFilters && Object.keys(crossFilters).length > 0) {
|
||||
source = source.filter((row) => {
|
||||
for (const [key, val] of Object.entries(crossFilters)) {
|
||||
if (val === undefined || val === null || val === '') continue;
|
||||
const cell = (row as any)[key];
|
||||
if (Array.isArray(val)) {
|
||||
if (val.length > 0 && !val.includes(String(cell ?? ''))) return false;
|
||||
} else if (String(cell ?? '') !== String(val)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
});
|
||||
}
|
||||
const seen = new Set<string>();
|
||||
let hasEmpty = false;
|
||||
for (const row of source) {
|
||||
const v = (row as any)[columnKey];
|
||||
if (v === undefined || v === null || v === '') {
|
||||
hasEmpty = true;
|
||||
continue;
|
||||
}
|
||||
seen.add(String(v));
|
||||
}
|
||||
const out: (string | null)[] = Array.from(seen).sort((a, b) => a.localeCompare(b));
|
||||
if (hasEmpty) out.push(null);
|
||||
return out;
|
||||
},
|
||||
[displayRows],
|
||||
);
|
||||
|
||||
const _hookData = useMemo(
|
||||
() => ({ fetchFilterValues: _fetchFilterValues }),
|
||||
[_fetchFilterValues],
|
||||
);
|
||||
|
||||
const columns = useMemo(() => {
|
||||
const raw: ColumnConfig[] = [
|
||||
{ key: 'id', label: t('Code'), sortable: true, filterable: true, width: 90 },
|
||||
{ key: 'label', label: t('Bezeichnung'), sortable: true, filterable: true, width: 200 },
|
||||
{
|
||||
key: 'status',
|
||||
label: t('Status'),
|
||||
sortable: true,
|
||||
filterable: true,
|
||||
width: 160,
|
||||
formatter: (_val: any, row: any) => {
|
||||
const r = row as LangRow;
|
||||
if (r.updating) {
|
||||
return (
|
||||
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 6, color: 'var(--color-warning, #e6a700)' }}>
|
||||
<FaSync style={{ animation: 'spin 1s linear infinite', fontSize: '0.85em' }} />
|
||||
{t('wird aktualisiert…')}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
if (r.status === 'generating') {
|
||||
return (
|
||||
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 6, color: 'var(--color-warning, #e6a700)' }}>
|
||||
<FaSync style={{ animation: 'spin 1s linear infinite', fontSize: '0.85em' }} />
|
||||
{t('wird erzeugt…')}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
return r.status;
|
||||
},
|
||||
},
|
||||
{ key: 'uiCount', label: t('UI'), sortable: true, width: 80 },
|
||||
{ key: 'gatewayCount', label: t('API'), sortable: true, width: 80 },
|
||||
{ key: 'entriesCount', label: t('Gesamt'), sortable: true, width: 80 },
|
||||
];
|
||||
return resolveColumnTypes(raw, langSetAttributes);
|
||||
}, [t, langSetAttributes]);
|
||||
|
||||
const existingCodes = useMemo(() => new Set(rows.map((r) => r.id)), [rows]);
|
||||
|
||||
const addChoices = useMemo(() => {
|
||||
const available = _isoChoices.filter((c) => !existingCodes.has(c.value));
|
||||
const available = isoCatalog.choices.filter((c) => !existingCodes.has(c.value));
|
||||
const priority = isoCatalog.priorityCodes;
|
||||
available.sort((a, b) => {
|
||||
const aPrio = _PRIORITY_CODES.indexOf(a.value);
|
||||
const bPrio = _PRIORITY_CODES.indexOf(b.value);
|
||||
const aPrio = priority.indexOf(a.value);
|
||||
const bPrio = priority.indexOf(b.value);
|
||||
if (aPrio !== -1 && bPrio !== -1) return aPrio - bPrio;
|
||||
if (aPrio !== -1) return -1;
|
||||
if (bPrio !== -1) return 1;
|
||||
return a.label.localeCompare(b.label);
|
||||
});
|
||||
return available;
|
||||
}, [existingCodes]);
|
||||
}, [existingCodes, isoCatalog]);
|
||||
|
||||
useEffect(() => {
|
||||
if (addChoices.length > 0 && (!addCode || !addChoices.find((c) => c.value === addCode))) {
|
||||
|
|
@ -890,11 +920,12 @@ export const AdminLanguagesPage: React.FC = () => {
|
|||
<div style={{ flex: 1, minHeight: 0, position: 'relative' }}>
|
||||
<FormGeneratorTable
|
||||
data={displayRows}
|
||||
columns={_getColumns(t)}
|
||||
columns={columns}
|
||||
loading={loading}
|
||||
pagination={false}
|
||||
selectable={false}
|
||||
searchable={false}
|
||||
hookData={_hookData}
|
||||
customActions={[
|
||||
{
|
||||
id: 'sync-xx',
|
||||
|
|
|
|||
|
|
@ -19,9 +19,12 @@ import { useMandateRoles, type Role, type RoleCreate, type RoleUpdate, type Pagi
|
|||
import { useUserMandates, type Mandate } from '../../hooks/useUserMandates';
|
||||
import { FormGeneratorTable } from '../../components/FormGenerator/FormGeneratorTable';
|
||||
import { FormGeneratorForm, type AttributeDefinition } from '../../components/FormGenerator/FormGeneratorForm';
|
||||
import { FaPlus, FaSync, FaUserShield, FaBuilding, FaGlobe, FaShieldAlt, FaCube } from 'react-icons/fa';
|
||||
import { FaPlus, FaSync, FaUserShield, FaBuilding, FaShieldAlt, FaCube } from 'react-icons/fa';
|
||||
import { useToast } from '../../contexts/ToastContext';
|
||||
import api from '../../api';
|
||||
import { useApiRequest } from '../../hooks/useApi';
|
||||
import { fetchAttributes } from '../../api/attributesApi';
|
||||
import { resolveColumnTypes } from '../../utils/columnTypeResolver';
|
||||
import type { ColumnConfig } from '../../components/FormGenerator/FormGeneratorTable';
|
||||
import styles from './Admin.module.css';
|
||||
|
||||
import { useLanguage } from '../../providers/language/LanguageContext';
|
||||
|
|
@ -31,6 +34,7 @@ export const AdminMandateRolesPage: React.FC = () => {
|
|||
const { t, currentLanguage } = useLanguage();
|
||||
|
||||
const navigate = useNavigate();
|
||||
const { request } = useApiRequest();
|
||||
const { showError, showWarning } = useToast();
|
||||
const {
|
||||
roles,
|
||||
|
|
@ -68,12 +72,10 @@ export const AdminMandateRolesPage: React.FC = () => {
|
|||
}
|
||||
};
|
||||
loadMandates();
|
||||
// Fetch Role attributes from backend
|
||||
api.get('/api/attributes/Role').then(response => {
|
||||
const attrs = response.data?.attributes || response.data || [];
|
||||
setBackendAttributes(Array.isArray(attrs) ? attrs : []);
|
||||
}).catch(() => setBackendAttributes([]));
|
||||
}, [fetchMandates]);
|
||||
fetchAttributes(request, 'RoleView')
|
||||
.then(setBackendAttributes)
|
||||
.catch(() => setBackendAttributes([]));
|
||||
}, [fetchMandates, request]);
|
||||
|
||||
// Load roles when mandate or scopeFilter changes
|
||||
useEffect(() => {
|
||||
|
|
@ -102,56 +104,23 @@ export const AdminMandateRolesPage: React.FC = () => {
|
|||
return String(desc);
|
||||
};
|
||||
|
||||
// Table columns - scopeType is now a backend-computed field
|
||||
const columns = useMemo(() => [
|
||||
{
|
||||
key: 'roleLabel',
|
||||
label: t('Bezeichnung'),
|
||||
type: 'string' as const,
|
||||
sortable: true,
|
||||
filterable: true,
|
||||
searchable: true,
|
||||
width: 150
|
||||
},
|
||||
const _rawColumns: ColumnConfig[] = useMemo(() => [
|
||||
{ key: 'roleLabel', sortable: true, filterable: true, searchable: true, width: 150 },
|
||||
{
|
||||
key: 'description',
|
||||
label: t('Beschreibung'),
|
||||
type: 'string' as const,
|
||||
sortable: false,
|
||||
filterable: false,
|
||||
width: 250,
|
||||
formatter: (value: string) => getDescriptionText(value)
|
||||
formatter: (value: string) => getDescriptionText(value),
|
||||
},
|
||||
{
|
||||
key: 'scopeType',
|
||||
label: t('Geltungsbereich'),
|
||||
type: 'string' as const,
|
||||
sortable: true,
|
||||
filterable: true,
|
||||
width: 140,
|
||||
formatter: (value: string) => {
|
||||
if (value === 'system') {
|
||||
return (
|
||||
<span className={styles.badge} style={{ background: 'var(--warning-color, #d69e2e)', color: 'white' }}>
|
||||
<FaUserShield style={{ marginRight: 4 }} /> {t('System-Template')}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
if (value === 'global') {
|
||||
return (
|
||||
<span className={styles.badge} style={{ background: 'var(--info-color, #3182ce)', color: 'white' }}>
|
||||
<FaGlobe style={{ marginRight: 4 }} /> {t('Template')}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<span className={styles.badge} style={{ background: 'var(--success-color, #38a169)', color: 'white' }}>
|
||||
<FaBuilding style={{ marginRight: 4 }} /> {t('Mandant')}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
},
|
||||
], [t]);
|
||||
{ key: 'scopeType', sortable: true, filterable: true, width: 160 },
|
||||
{ key: 'userCount', sortable: true, filterable: true, width: 100 },
|
||||
], []);
|
||||
|
||||
const columns = useMemo(
|
||||
() => resolveColumnTypes(_rawColumns, backendAttributes),
|
||||
[_rawColumns, backendAttributes],
|
||||
);
|
||||
|
||||
// Form attributes from backend - for create form
|
||||
const createFields: AttributeDefinition[] = useMemo(() => {
|
||||
|
|
|
|||
|
|
@ -11,7 +11,10 @@ import { FormGeneratorTable } from '../../components/FormGenerator/FormGenerator
|
|||
import { FormGeneratorForm, type AttributeDefinition } from '../../components/FormGenerator/FormGeneratorForm';
|
||||
import { FaPlus, FaSync, FaBuilding } from 'react-icons/fa';
|
||||
import { useToast } from '../../contexts/ToastContext';
|
||||
import api from '../../api';
|
||||
import { useApiRequest } from '../../hooks/useApi';
|
||||
import { fetchAttributes } from '../../api/attributesApi';
|
||||
import { resolveColumnTypes } from '../../utils/columnTypeResolver';
|
||||
import type { ColumnConfig } from '../../components/FormGenerator/FormGeneratorTable';
|
||||
import styles from './Admin.module.css';
|
||||
|
||||
import { useLanguage } from '../../providers/language/LanguageContext';
|
||||
|
|
@ -21,6 +24,7 @@ export const AdminUserMandatesPage: React.FC = () => {
|
|||
const { t } = useLanguage();
|
||||
|
||||
const { showError } = useToast();
|
||||
const { request } = useApiRequest();
|
||||
const {
|
||||
users,
|
||||
loading,
|
||||
|
|
@ -59,12 +63,10 @@ export const AdminUserMandatesPage: React.FC = () => {
|
|||
}
|
||||
};
|
||||
loadMandates();
|
||||
// Fetch UserMandate attributes from backend (for table columns)
|
||||
api.get('/api/attributes/UserMandate').then(response => {
|
||||
const attrs = response.data?.attributes || response.data || [];
|
||||
setBackendAttributes(Array.isArray(attrs) ? attrs : []);
|
||||
}).catch(() => setBackendAttributes([]));
|
||||
}, [fetchMandates]);
|
||||
fetchAttributes(request, 'UserMandateView')
|
||||
.then(setBackendAttributes)
|
||||
.catch(() => setBackendAttributes([]));
|
||||
}, [fetchMandates, request]);
|
||||
|
||||
// Load users when mandate changes
|
||||
useEffect(() => {
|
||||
|
|
@ -97,60 +99,57 @@ export const AdminUserMandatesPage: React.FC = () => {
|
|||
return allUsers.filter(u => !existingUserIds.has(u.id));
|
||||
}, [allUsers, users]);
|
||||
|
||||
// Table columns - based on MandateUserInfo response structure
|
||||
const columns = useMemo(() => {
|
||||
return [
|
||||
{
|
||||
key: 'username',
|
||||
label: t('Benutzername'),
|
||||
type: 'text' as any,
|
||||
sortable: true,
|
||||
filterable: true,
|
||||
searchable: true,
|
||||
width: 150,
|
||||
const _rawColumns: ColumnConfig[] = useMemo(() => [
|
||||
{
|
||||
key: 'username',
|
||||
label: t('Benutzername'),
|
||||
sortable: true,
|
||||
filterable: true,
|
||||
searchable: true,
|
||||
width: 150,
|
||||
},
|
||||
{
|
||||
key: 'email',
|
||||
label: t('E-Mail'),
|
||||
sortable: true,
|
||||
filterable: true,
|
||||
searchable: true,
|
||||
width: 200,
|
||||
},
|
||||
{
|
||||
key: 'fullName',
|
||||
label: t('Vollständiger Name'),
|
||||
sortable: true,
|
||||
filterable: true,
|
||||
searchable: true,
|
||||
width: 180,
|
||||
},
|
||||
{
|
||||
key: 'roleLabels',
|
||||
label: t('Rollen'),
|
||||
sortable: false,
|
||||
filterable: false,
|
||||
searchable: true,
|
||||
width: 200,
|
||||
formatter: (value: string[]) => {
|
||||
if (!value || value.length === 0) return '-';
|
||||
return value.join(', ');
|
||||
},
|
||||
{
|
||||
key: 'email',
|
||||
label: t('E-Mail'),
|
||||
type: 'text' as any,
|
||||
sortable: true,
|
||||
filterable: true,
|
||||
searchable: true,
|
||||
width: 200,
|
||||
},
|
||||
{
|
||||
key: 'fullName',
|
||||
label: t('Vollständiger Name'),
|
||||
type: 'text' as any,
|
||||
sortable: true,
|
||||
filterable: true,
|
||||
searchable: true,
|
||||
width: 180,
|
||||
},
|
||||
{
|
||||
key: 'roleLabels',
|
||||
label: t('Rollen'),
|
||||
type: 'text' as any,
|
||||
sortable: false,
|
||||
filterable: false,
|
||||
searchable: true,
|
||||
width: 200,
|
||||
render: (value: string[]) => {
|
||||
if (!value || value.length === 0) return '-';
|
||||
return value.join(', ');
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'enabled',
|
||||
label: t('Aktiv'),
|
||||
type: 'boolean' as any,
|
||||
sortable: true,
|
||||
filterable: true,
|
||||
searchable: false,
|
||||
width: 80,
|
||||
},
|
||||
];
|
||||
}, [t]);
|
||||
},
|
||||
{
|
||||
key: 'enabled',
|
||||
label: t('Aktiv'),
|
||||
sortable: true,
|
||||
filterable: true,
|
||||
searchable: false,
|
||||
width: 80,
|
||||
},
|
||||
], [t]);
|
||||
|
||||
const columns = useMemo(
|
||||
() => resolveColumnTypes(_rawColumns, backendAttributes),
|
||||
[_rawColumns, backendAttributes],
|
||||
);
|
||||
|
||||
// Dynamic options for forms (users and roles)
|
||||
const userOptions = useMemo(() =>
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ import styles from './Admin.module.css';
|
|||
import { getUserDataCache } from '../../utils/userCache';
|
||||
|
||||
import { useLanguage } from '../../providers/language/LanguageContext';
|
||||
import { resolveColumnTypes } from '../../utils/columnTypeResolver';
|
||||
|
||||
const _PRIVILEGED_FLAGS = ['isSysAdmin', 'isPlatformAdmin'] as const;
|
||||
|
||||
|
|
@ -57,21 +58,20 @@ export const AdminUsersPage: React.FC = () => {
|
|||
const [showCreateModal, setShowCreateModal] = useState(false);
|
||||
const [editingUser, setEditingUser] = useState<User | null>(null);
|
||||
|
||||
// Generate columns from attributes
|
||||
// Generate columns from attributes; types from backend via resolveColumnTypes
|
||||
const columns = useMemo(() => {
|
||||
return (attributes || []).map(attr => ({
|
||||
const raw = (attributes || []).map(attr => ({
|
||||
key: attr.name,
|
||||
label: attr.label || attr.name,
|
||||
type: attr.type as any,
|
||||
sortable: attr.sortable !== false,
|
||||
filterable: attr.filterable !== false,
|
||||
searchable: attr.searchable !== false,
|
||||
width: attr.width || 150,
|
||||
minWidth: attr.minWidth || 100,
|
||||
maxWidth: attr.maxWidth || 400,
|
||||
fkSource: (attr as any).fkSource,
|
||||
fkDisplayField: (attr as any).fkDisplayField,
|
||||
displayField: (attr as any).displayField,
|
||||
}));
|
||||
return resolveColumnTypes(raw, attributes || []);
|
||||
}, [attributes]);
|
||||
|
||||
// Check permissions
|
||||
|
|
|
|||
|
|
@ -38,7 +38,7 @@ interface DispatchResult {
|
|||
username?: string;
|
||||
success: boolean;
|
||||
error?: string;
|
||||
emailSent?: boolean;
|
||||
emailSentFlag?: boolean;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
|
|
@ -254,7 +254,7 @@ export const AdminInvitationWizardPage: React.FC = () => {
|
|||
email: emailTrim,
|
||||
username: inv.username,
|
||||
success: true,
|
||||
emailSent: result.data?.emailSent,
|
||||
emailSentFlag: result.data?.emailSentFlag,
|
||||
});
|
||||
} else {
|
||||
results.push({
|
||||
|
|
@ -731,7 +731,7 @@ export const AdminInvitationWizardPage: React.FC = () => {
|
|||
{r.success ? t('Erfolgreich') : r.error || t('Fehler')}
|
||||
</span>
|
||||
</td>
|
||||
<td style={{ padding: '8px' }}>{r.emailSent ? t('Ja') : t('—')}</td>
|
||||
<td style={{ padding: '8px' }}>{r.emailSentFlag ? t('Ja') : t('—')}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ import { getApiBaseUrl } from '../../../config/config';
|
|||
import styles from '../admin/Admin.module.css';
|
||||
|
||||
import { useLanguage } from '../../providers/language/LanguageContext';
|
||||
import { resolveColumnTypes } from '../../utils/columnTypeResolver';
|
||||
|
||||
export const ConnectionsPage: React.FC = () => {
|
||||
const { t } = useLanguage();
|
||||
|
|
@ -54,33 +55,25 @@ export const ConnectionsPage: React.FC = () => {
|
|||
const columns = useMemo(() => {
|
||||
const hiddenColumns = ['id', 'externalId', 'tokenStatus', 'tokenExpiresAt', 'grantedScopes'];
|
||||
|
||||
return (attributes || [])
|
||||
const raw = (attributes || [])
|
||||
.filter(attr => !hiddenColumns.includes(attr.name))
|
||||
.map(attr => {
|
||||
const col: any = {
|
||||
key: attr.name,
|
||||
label: attr.label || attr.name,
|
||||
type: attr.type as any,
|
||||
label: attr.name === 'userId' ? t('Benutzer') : attr.label || attr.name,
|
||||
sortable: attr.sortable !== false,
|
||||
filterable: attr.filterable !== false,
|
||||
searchable: attr.searchable !== false,
|
||||
width: attr.width || 150,
|
||||
minWidth: attr.minWidth || 100,
|
||||
maxWidth: attr.maxWidth || 400,
|
||||
fkSource: (attr as any).fkSource,
|
||||
fkDisplayField: (attr as any).fkDisplayField,
|
||||
displayField: (attr as any).displayField,
|
||||
frontendFormat: (attr as any).frontendFormat,
|
||||
frontendFormatLabels: (attr as any).frontendFormatLabels,
|
||||
};
|
||||
|
||||
if (attr.name === 'userId') {
|
||||
col.fkSource = '/api/users/';
|
||||
col.fkDisplayField = 'username';
|
||||
col.label = t('Benutzer');
|
||||
}
|
||||
|
||||
return col;
|
||||
});
|
||||
return resolveColumnTypes(raw, attributes || []);
|
||||
}, [attributes, t]);
|
||||
|
||||
// Check permissions
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ import { usePrompt } from '../../hooks/usePrompt';
|
|||
import styles from '../admin/Admin.module.css';
|
||||
import { useLanguage } from '../../providers/language/LanguageContext';
|
||||
import { getUserDataCache } from '../../utils/userCache';
|
||||
import { resolveColumnTypes } from '../../utils/columnTypeResolver';
|
||||
|
||||
interface UserFile {
|
||||
id: string;
|
||||
|
|
@ -203,32 +204,28 @@ export const FilesPage: React.FC = () => {
|
|||
.map(attr => ({
|
||||
key: attr.name,
|
||||
label: attr.label || attr.name,
|
||||
type: attr.type as any,
|
||||
sortable: attr.sortable !== false,
|
||||
filterable: attr.filterable !== false,
|
||||
searchable: attr.searchable !== false,
|
||||
width: attr.width || 150,
|
||||
minWidth: attr.minWidth || 100,
|
||||
maxWidth: attr.maxWidth || 400,
|
||||
fkSource: (attr as any).fkSource,
|
||||
fkDisplayField: (attr as any).fkDisplayField,
|
||||
displayField: (attr as any).displayField,
|
||||
frontendFormat: (attr as any).frontendFormat,
|
||||
frontendFormatLabels: (attr as any).frontendFormatLabels,
|
||||
}));
|
||||
cols.push({
|
||||
key: 'sysCreatedBy',
|
||||
label: t('Erstellt von'),
|
||||
type: 'text' as any,
|
||||
sortable: true,
|
||||
filterable: true,
|
||||
searchable: true,
|
||||
width: 150,
|
||||
minWidth: 100,
|
||||
maxWidth: 250,
|
||||
fkSource: '/api/users/',
|
||||
fkDisplayField: 'username',
|
||||
displayField: 'sysCreatedByLabel',
|
||||
} as any);
|
||||
return cols;
|
||||
return resolveColumnTypes(cols, attributes || []);
|
||||
}, [attributes, t]);
|
||||
|
||||
const canCreate = permissions?.create !== 'n';
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ import { FaSync, FaPlus } from 'react-icons/fa';
|
|||
import styles from '../admin/Admin.module.css';
|
||||
|
||||
import { useLanguage } from '../../providers/language/LanguageContext';
|
||||
import { resolveColumnTypes } from '../../utils/columnTypeResolver';
|
||||
|
||||
interface Prompt {
|
||||
id: string;
|
||||
|
|
@ -76,15 +77,13 @@ export const PromptsPage: React.FC = () => {
|
|||
.map(attr => ({
|
||||
key: attr.name,
|
||||
label: attr.label || attr.name,
|
||||
type: attr.type as any,
|
||||
sortable: attr.sortable !== false,
|
||||
filterable: attr.filterable !== false,
|
||||
searchable: attr.searchable !== false,
|
||||
width: attr.name === 'content' ? 300 : attr.width || 150,
|
||||
minWidth: attr.minWidth || 100,
|
||||
maxWidth: attr.name === 'content' ? 500 : attr.maxWidth || 400,
|
||||
fkSource: (attr as any).fkSource,
|
||||
fkDisplayField: (attr as any).fkDisplayField,
|
||||
displayField: (attr as any).displayField,
|
||||
frontendFormat: (attr as any).frontendFormat,
|
||||
frontendFormatLabels: (attr as any).frontendFormatLabels,
|
||||
}));
|
||||
|
|
@ -93,20 +92,18 @@ export const PromptsPage: React.FC = () => {
|
|||
cols.push({
|
||||
key: 'sysCreatedBy',
|
||||
label: t('Erstellt von'),
|
||||
type: 'text' as any,
|
||||
sortable: true,
|
||||
filterable: true,
|
||||
searchable: true,
|
||||
width: 150,
|
||||
minWidth: 100,
|
||||
maxWidth: 250,
|
||||
fkSource: '/api/users/',
|
||||
fkDisplayField: 'username',
|
||||
displayField: 'sysCreatedByLabel',
|
||||
frontendFormat: undefined,
|
||||
frontendFormatLabels: undefined,
|
||||
});
|
||||
|
||||
return cols;
|
||||
return resolveColumnTypes(cols, attributes || []);
|
||||
}, [attributes, t]);
|
||||
|
||||
// Check permissions
|
||||
|
|
|
|||
|
|
@ -1,7 +1,11 @@
|
|||
import React, { useCallback } from 'react';
|
||||
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { FormGeneratorTable, type ColumnConfig } from '../../components/FormGenerator/FormGeneratorTable';
|
||||
import { useAdminSubscriptions } from '../../hooks/useAdminSubscriptions';
|
||||
import { useConfirm } from '../../hooks/useConfirm';
|
||||
import { useApiRequest } from '../../hooks/useApi';
|
||||
import { fetchAttributes } from '../../api/attributesApi';
|
||||
import type { AttributeDefinition } from '../../api/attributesApi';
|
||||
import { resolveColumnTypes } from '../../utils/columnTypeResolver';
|
||||
import api from '../../api';
|
||||
import styles from './Billing.module.css';
|
||||
|
||||
|
|
@ -9,28 +13,39 @@ import { useLanguage } from '../../providers/language/LanguageContext';
|
|||
|
||||
const _TERMINAL_STATUSES = new Set(['EXPIRED']);
|
||||
|
||||
function _getColumns(t: (key: string) => string): ColumnConfig[] {
|
||||
return [
|
||||
{ key: 'mandateName', label: t('Mandant'), type: 'text', sortable: true, filterable: true, width: 180 },
|
||||
{ key: 'planTitle', label: t('Plan'), type: 'text', sortable: true, filterable: true, width: 180 },
|
||||
{ key: 'status', label: t('Status'), type: 'text', sortable: true, filterable: true, width: 110 },
|
||||
{ key: 'recurring', label: t('Wiederkehrend'), type: 'boolean', sortable: true, filterable: true, width: 120 },
|
||||
{ key: 'activeUsers', label: t('Benutzer'), type: 'number', sortable: true, width: 70 },
|
||||
{ key: 'activeInstances', label: t('Module'), type: 'number', sortable: true, width: 90 },
|
||||
{ key: 'monthlyRevenueCHF', label: t('Umsatz pro Monat'), type: 'number', sortable: true, width: 140 },
|
||||
{ key: 'startedAt', label: t('Gestartet'), type: 'date', sortable: true, filterable: true, width: 130 },
|
||||
{ key: 'currentPeriodEnd', label: t('Periodenende'), type: 'date', sortable: true, filterable: true, width: 130 },
|
||||
{ key: 'snapshotPricePerUserCHF', label: t('Preis pro Benutzer'), type: 'number', sortable: true, width: 100 },
|
||||
{ key: 'snapshotPricePerInstanceCHF', label: t('Preis pro Modul'), type: 'number', sortable: true, width: 110 },
|
||||
];
|
||||
}
|
||||
|
||||
const AdminSubscriptionsPage: React.FC = () => {
|
||||
const { t } = useLanguage();
|
||||
const { t } = useLanguage();
|
||||
const { request } = useApiRequest();
|
||||
const [backendAttributes, setBackendAttributes] = useState<AttributeDefinition[]>([]);
|
||||
|
||||
const { confirm, ConfirmDialog } = useConfirm();
|
||||
const { data: subscriptions, pagination, loading, refetch } = useAdminSubscriptions();
|
||||
|
||||
useEffect(() => {
|
||||
fetchAttributes(request, 'MandateSubscriptionView')
|
||||
.then(setBackendAttributes)
|
||||
.catch(() => setBackendAttributes([]));
|
||||
}, [request]);
|
||||
|
||||
const _rawColumns: ColumnConfig[] = useMemo(() => [
|
||||
{ key: 'mandateName', label: t('Mandant'), sortable: true, filterable: true, width: 180 },
|
||||
{ key: 'planTitle', label: t('Plan'), sortable: true, filterable: true, width: 180 },
|
||||
{ key: 'status', label: t('Status'), sortable: true, filterable: true, width: 110 },
|
||||
{ key: 'recurring', label: t('Wiederkehrend'), sortable: true, filterable: true, width: 120 },
|
||||
{ key: 'activeUsers', label: t('Benutzer'), sortable: true, width: 70 },
|
||||
{ key: 'activeInstances', label: t('Module'), sortable: true, width: 90 },
|
||||
{ key: 'monthlyRevenueCHF', label: t('Umsatz pro Monat'), sortable: true, width: 140 },
|
||||
{ key: 'startedAt', label: t('Gestartet'), sortable: true, filterable: true, width: 130 },
|
||||
{ key: 'currentPeriodEnd', label: t('Periodenende'), sortable: true, filterable: true, width: 130 },
|
||||
{ key: 'snapshotPricePerUserCHF', label: t('Preis pro Benutzer'), sortable: true, width: 100 },
|
||||
{ key: 'snapshotPricePerInstanceCHF', label: t('Preis pro Modul'), sortable: true, width: 110 },
|
||||
], [t]);
|
||||
|
||||
const columns = useMemo(
|
||||
() => resolveColumnTypes(_rawColumns, backendAttributes),
|
||||
[_rawColumns, backendAttributes],
|
||||
);
|
||||
|
||||
const _handleForceCancel = useCallback(async (row: any) => {
|
||||
const ok = await confirm(
|
||||
t('Subscription «{plan}» für Mandant «{mandate}» sofort kündigen? Dies wird auch auf Stripe sofort storniert.', { plan: row.planTitle, mandate: row.mandateName }),
|
||||
|
|
@ -44,7 +59,7 @@ const AdminSubscriptionsPage: React.FC = () => {
|
|||
} catch (err) {
|
||||
console.error('Force cancel failed:', err);
|
||||
}
|
||||
}, [confirm, refetch]);
|
||||
}, [confirm, refetch, t]);
|
||||
|
||||
return (
|
||||
<div className={styles.billingDashboard} style={{ minHeight: 0 }}>
|
||||
|
|
@ -56,7 +71,7 @@ const AdminSubscriptionsPage: React.FC = () => {
|
|||
<div style={{ flex: 1, minHeight: 0, overflow: 'auto' }}>
|
||||
<FormGeneratorTable
|
||||
data={subscriptions}
|
||||
columns={_getColumns(t)}
|
||||
columns={columns}
|
||||
apiEndpoint="/api/subscription/admin/all"
|
||||
loading={loading}
|
||||
pagination={true}
|
||||
|
|
|
|||
|
|
@ -13,6 +13,10 @@ import { FormGeneratorTable, ColumnConfig } from '../../components/FormGenerator
|
|||
import { FormGeneratorReport } from '../../components/FormGenerator/FormGeneratorReport';
|
||||
import type { ReportSection, ReportFilterState, ReportChartDataPoint, ReportDateRangeSelectorConfig } from '../../components/FormGenerator/FormGeneratorReport';
|
||||
import api from '../../api';
|
||||
import { useApiRequest } from '../../hooks/useApi';
|
||||
import { fetchAttributes } from '../../api/attributesApi';
|
||||
import type { AttributeDefinition } from '../../api/attributesApi';
|
||||
import { resolveColumnTypes } from '../../utils/columnTypeResolver';
|
||||
import { useBilling, type BillingBucketSize } from '../../hooks/useBilling';
|
||||
import { UserTransaction } from '../../api/billingApi';
|
||||
import { formatBinaryDataSizeFromMebibytes } from '../../utils/formatDataSize';
|
||||
|
|
@ -252,6 +256,8 @@ function _buildDiagramSections(viewStats: ViewStatistics, chartMode: 'pie' | 'ba
|
|||
|
||||
export const BillingDataView: React.FC = () => {
|
||||
const { t } = useLanguage();
|
||||
const { request } = useApiRequest();
|
||||
const [billingTxnAttributes, setBillingTxnAttributes] = useState<AttributeDefinition[]>([]);
|
||||
const [activeTab, setActiveTab] = useState<TabType>('overview');
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const [checkoutMessage, setCheckoutMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null);
|
||||
|
|
@ -338,6 +344,12 @@ export const BillingDataView: React.FC = () => {
|
|||
const [transactionsError, setTransactionsError] = useState<string | null>(null);
|
||||
const [transactionsPagination, setTransactionsPagination] = useState<any>(null);
|
||||
|
||||
useEffect(() => {
|
||||
fetchAttributes(request, 'BillingTransactionView')
|
||||
.then(setBillingTxnAttributes)
|
||||
.catch(() => setBillingTxnAttributes([]));
|
||||
}, [request]);
|
||||
|
||||
// Unified scope params -- single source of truth for all tab API calls
|
||||
// "nur meine Daten" is an additional filter on top of the dropdown scope
|
||||
const _scopeParams = useMemo((): Record<string, string> => {
|
||||
|
|
@ -512,19 +524,23 @@ export const BillingDataView: React.FC = () => {
|
|||
fetchFilterValues: _fetchTransactionFilterValues,
|
||||
}), [_loadTransactions, transactionsPagination, _fetchTransactionFilterValues]);
|
||||
|
||||
// Table column definitions
|
||||
const columns: ColumnConfig[] = useMemo(() => [
|
||||
{ key: 'createdAt', label: t('Datum'), type: 'timestamp' as any, sortable: true, width: 160 },
|
||||
{ key: 'mandateName', label: t('Mandant'), type: 'text' as any, sortable: true, filterable: true, searchable: true, width: 150 },
|
||||
{ key: 'userName', label: t('Benutzer'), type: 'text' as any, sortable: true, filterable: true, searchable: true, width: 150 },
|
||||
{ key: 'transactionType', label: t('Typ'), type: 'text' as any, sortable: true, filterable: true, width: 100 },
|
||||
{ key: 'description', label: t('Beschreibung'), type: 'text' as any, searchable: true, width: 250 },
|
||||
{ key: 'aicoreProvider', label: t('Anbieter'), type: 'text' as any, sortable: true, filterable: true, width: 120 },
|
||||
{ key: 'aicoreModel', label: t('Modell'), type: 'text' as any, sortable: true, filterable: true, width: 150 },
|
||||
{ key: 'featureCode', label: t('Feature'), type: 'text' as any, sortable: true, filterable: true, width: 120 },
|
||||
{ key: 'amount', label: t('Betrag (CHF)'), type: 'number' as any, sortable: true, searchable: true, width: 120 },
|
||||
const _rawTransactionColumns: ColumnConfig[] = useMemo(() => [
|
||||
{ key: 'sysCreatedAt', label: t('Datum'), sortable: true, width: 160 },
|
||||
{ key: 'mandateName', label: t('Mandant'), sortable: true, filterable: true, searchable: true, width: 150 },
|
||||
{ key: 'userName', label: t('Benutzer'), sortable: true, filterable: true, searchable: true, width: 150 },
|
||||
{ key: 'transactionType', label: t('Typ'), sortable: true, filterable: true, width: 100 },
|
||||
{ key: 'description', label: t('Beschreibung'), searchable: true, width: 250 },
|
||||
{ key: 'aicoreProvider', label: t('Anbieter'), sortable: true, filterable: true, width: 120 },
|
||||
{ key: 'aicoreModel', label: t('Modell'), sortable: true, filterable: true, width: 150 },
|
||||
{ key: 'featureCode', label: t('Feature'), sortable: true, filterable: true, width: 120 },
|
||||
{ key: 'amount', label: t('Betrag (CHF)'), sortable: true, searchable: true, width: 120 },
|
||||
], [t]);
|
||||
|
||||
const columns: ColumnConfig[] = useMemo(
|
||||
() => resolveColumnTypes(_rawTransactionColumns, billingTxnAttributes),
|
||||
[_rawTransactionColumns, billingTxnAttributes],
|
||||
);
|
||||
|
||||
const totalBalance = useMemo(() => {
|
||||
const filtered = selectedScope === 'personal' || selectedScope === 'all'
|
||||
? balances
|
||||
|
|
|
|||
|
|
@ -145,7 +145,7 @@ const TransactionTable: React.FC<TransactionTableProps> = ({ transactions }) =>
|
|||
<tbody>
|
||||
{transactions.map((txn) => (
|
||||
<tr key={txn.id}>
|
||||
<td>{formatDate(txn.createdAt)}</td>
|
||||
<td>{formatDate(txn.sysCreatedAt)}</td>
|
||||
<td>{txn.mandateName || '-'}</td>
|
||||
<td>
|
||||
<span className={`${styles.transactionType} ${getTypeClass(txn.transactionType)}`}>
|
||||
|
|
|
|||
|
|
@ -58,7 +58,7 @@ const TransactionRow: React.FC<TransactionRowProps> = ({ transaction }) => {
|
|||
|
||||
return (
|
||||
<tr>
|
||||
<td>{formatDate(transaction.createdAt)}</td>
|
||||
<td>{formatDate(transaction.sysCreatedAt)}</td>
|
||||
<td>{transaction.mandateName || '-'}</td>
|
||||
<td>
|
||||
<span className={`${styles.transactionType} ${getTypeClass(transaction.transactionType)}`}>
|
||||
|
|
|
|||
|
|
@ -239,7 +239,7 @@ const UserTransactionTable: React.FC<UserTransactionTableProps> = ({
|
|||
<tbody>
|
||||
{filteredTransactions.map((txn) => (
|
||||
<tr key={txn.id}>
|
||||
<td>{formatDate(txn.createdAt)}</td>
|
||||
<td>{formatDate(txn.sysCreatedAt)}</td>
|
||||
<td>{txn.mandateName || '-'}</td>
|
||||
<td>{txn.userName || '-'}</td>
|
||||
<td>
|
||||
|
|
|
|||
|
|
@ -4,6 +4,10 @@
|
|||
* Keeps the CommCoach dossier/coaching page mounted across route changes.
|
||||
* Visibility is toggled via CSS so session state, messages, and input state
|
||||
* stay alive when the user leaves and later returns.
|
||||
*
|
||||
* Persistence is scoped per `(mandateId, instanceId)` — switching to a
|
||||
* different mandate or instance via the navigator unmounts the previous
|
||||
* view and mounts a fresh one.
|
||||
*/
|
||||
|
||||
import React, { useRef } from 'react';
|
||||
|
|
@ -30,6 +34,7 @@ export const CommcoachKeepAlive: React.FC<CommcoachKeepAliveProps> = ({ isVisibl
|
|||
const mandateId = cachedMandateIdRef.current;
|
||||
const instanceId = cachedInstanceIdRef.current;
|
||||
if (!mandateId || !instanceId) return null;
|
||||
const scopeKey = `${mandateId}:${instanceId}`;
|
||||
|
||||
return (
|
||||
<div
|
||||
|
|
@ -44,6 +49,7 @@ export const CommcoachKeepAlive: React.FC<CommcoachKeepAliveProps> = ({ isVisibl
|
|||
}}
|
||||
>
|
||||
<CommcoachDossierView
|
||||
key={scopeKey}
|
||||
persistentInstanceId={instanceId}
|
||||
persistentMandateId={mandateId}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,96 @@
|
|||
// Copyright (c) 2025 Patrick Motsch
|
||||
// All rights reserved.
|
||||
//
|
||||
// Persistence is per (mandateId, instanceId): switching to a different mandate
|
||||
// or instance must remount the editor page so its internal state (loaded
|
||||
// workflow, currentWorkflowId, …) is reset and saves go to the right tenant.
|
||||
|
||||
import React from 'react';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { render, screen, act } from '@testing-library/react';
|
||||
import { MemoryRouter, useNavigate } from 'react-router-dom';
|
||||
|
||||
const _mountCount = { value: 0 };
|
||||
|
||||
vi.mock('./GraphicalEditorPage', () => ({
|
||||
GraphicalEditorPage: ({ persistentMandateId, persistentInstanceId }: { persistentMandateId?: string; persistentInstanceId?: string }) => {
|
||||
React.useEffect(() => {
|
||||
_mountCount.value += 1;
|
||||
}, []);
|
||||
return <div data-testid="ge-page">{persistentMandateId}::{persistentInstanceId}</div>;
|
||||
},
|
||||
}));
|
||||
|
||||
import { GraphicalEditorKeepAlive } from './GraphicalEditorKeepAlive';
|
||||
|
||||
let _navigateTo: ((path: string) => void) | null = null;
|
||||
const _NavCapture: React.FC = () => {
|
||||
_navigateTo = useNavigate();
|
||||
return null;
|
||||
};
|
||||
|
||||
function _renderHarness(initialPath: string) {
|
||||
return render(
|
||||
<MemoryRouter initialEntries={[initialPath]}>
|
||||
<_NavCapture />
|
||||
<GraphicalEditorKeepAlive isVisible />
|
||||
</MemoryRouter>,
|
||||
);
|
||||
}
|
||||
|
||||
function _navigate(path: string) {
|
||||
act(() => {
|
||||
_navigateTo?.(path);
|
||||
});
|
||||
}
|
||||
|
||||
describe('GraphicalEditorKeepAlive — persistence per (mandate, instance)', () => {
|
||||
it('remounts the page when the mandate changes', () => {
|
||||
_mountCount.value = 0;
|
||||
_renderHarness('/mandates/mA/graphicalEditor/iA/editor');
|
||||
expect(_mountCount.value).toBe(1);
|
||||
expect(screen.getByTestId('ge-page').textContent).toBe('mA::iA');
|
||||
|
||||
_navigate('/mandates/mB/graphicalEditor/iA/editor');
|
||||
|
||||
expect(_mountCount.value).toBe(2);
|
||||
expect(screen.getByTestId('ge-page').textContent).toBe('mB::iA');
|
||||
});
|
||||
|
||||
it('remounts the page when the instance changes', () => {
|
||||
_mountCount.value = 0;
|
||||
_renderHarness('/mandates/mA/graphicalEditor/iA/editor');
|
||||
expect(_mountCount.value).toBe(1);
|
||||
|
||||
_navigate('/mandates/mA/graphicalEditor/iZ/editor');
|
||||
|
||||
expect(_mountCount.value).toBe(2);
|
||||
expect(screen.getByTestId('ge-page').textContent).toBe('mA::iZ');
|
||||
});
|
||||
|
||||
it('does NOT remount when the route stays on the same (mandate, instance)', () => {
|
||||
_mountCount.value = 0;
|
||||
_renderHarness('/mandates/mA/graphicalEditor/iA/editor');
|
||||
expect(_mountCount.value).toBe(1);
|
||||
|
||||
_navigate('/mandates/mA/graphicalEditor/iA/editor');
|
||||
|
||||
expect(_mountCount.value).toBe(1);
|
||||
});
|
||||
|
||||
it('keeps the cached page mounted (no remount) when the user navigates AWAY and BACK to the same scope', () => {
|
||||
_mountCount.value = 0;
|
||||
_renderHarness('/mandates/mA/graphicalEditor/iA/editor');
|
||||
expect(_mountCount.value).toBe(1);
|
||||
|
||||
// Away to a non-editor route: the regex match fails, refs keep their
|
||||
// previous values — the cached page must not remount.
|
||||
_navigate('/admin/languages');
|
||||
expect(_mountCount.value).toBe(1);
|
||||
expect(screen.getByTestId('ge-page').textContent).toBe('mA::iA');
|
||||
|
||||
// Back to the same (mandate, instance) — still no remount.
|
||||
_navigate('/mandates/mA/graphicalEditor/iA/editor');
|
||||
expect(_mountCount.value).toBe(1);
|
||||
});
|
||||
});
|
||||
|
|
@ -4,9 +4,16 @@
|
|||
* Keeps the GraphicalEditorPage mounted across route changes so the canvas
|
||||
* state, SSE connections, and editor context survive navigation to ANY page
|
||||
* (other features, admin, settings, etc.).
|
||||
* Visibility is toggled via CSS `display` instead of mount / unmount.
|
||||
* Cached mandateId/instanceId are passed as props so the page does not
|
||||
* depend on URL params (which disappear on non-feature routes).
|
||||
*
|
||||
* Persistence is scoped per `(mandateId, instanceId)`: when the user switches
|
||||
* to a DIFFERENT mandate or instance via the navigator, the previous editor
|
||||
* mount is discarded and a fresh page is mounted. Otherwise stale state from
|
||||
* mandate A leaks into mandate B and saves end up hitting the wrong tenant
|
||||
* (HTTP 404 / "not found").
|
||||
*
|
||||
* Implementation: feeds the cached `(mandate, instance)` tuple into both
|
||||
* `props` and `key`. React reuses the mount as long as the tuple stays
|
||||
* identical and unmounts/remounts on change.
|
||||
*/
|
||||
|
||||
import React, { useRef } from 'react';
|
||||
|
|
@ -34,6 +41,10 @@ export const GraphicalEditorKeepAlive: React.FC<GraphicalEditorKeepAliveProps> =
|
|||
|
||||
if (!hasEverMountedRef.current) return null;
|
||||
|
||||
const mandateId = cachedMandateIdRef.current;
|
||||
const instanceId = cachedInstanceIdRef.current;
|
||||
const scopeKey = `${mandateId}:${instanceId}`;
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
|
|
@ -48,8 +59,9 @@ export const GraphicalEditorKeepAlive: React.FC<GraphicalEditorKeepAliveProps> =
|
|||
}}
|
||||
>
|
||||
<GraphicalEditorPage
|
||||
persistentInstanceId={cachedInstanceIdRef.current}
|
||||
persistentMandateId={cachedMandateIdRef.current}
|
||||
key={scopeKey}
|
||||
persistentInstanceId={instanceId}
|
||||
persistentMandateId={mandateId}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -22,6 +22,9 @@ import {
|
|||
type AutoWorkflowTemplate,
|
||||
type AutoTemplateScope,
|
||||
} from '../../../api/workflowApi';
|
||||
import { fetchAttributes } from '../../../api/attributesApi';
|
||||
import type { AttributeDefinition } from '../../../api/attributesApi';
|
||||
import { resolveColumnTypes } from '../../../utils/columnTypeResolver';
|
||||
import { useToast } from '../../../contexts/ToastContext';
|
||||
import { formatUnixTimestamp } from '../../../utils/time';
|
||||
import styles from '../../../pages/admin/Admin.module.css';
|
||||
|
|
@ -68,6 +71,15 @@ export const GraphicalEditorTemplatesPage: React.FC = () => {
|
|||
const [sharingId, setSharingId] = useState<string | null>(null);
|
||||
|
||||
const [paginationMeta, setPaginationMeta] = useState<any>(null);
|
||||
const [backendAttributes, setBackendAttributes] = useState<AttributeDefinition[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchAttributes(request, 'Automation2WorkflowView')
|
||||
.then(setBackendAttributes)
|
||||
.catch((err) => {
|
||||
console.error('[graphicalEditor] fetchAttributes Automation2WorkflowView failed', err);
|
||||
});
|
||||
}, [request]);
|
||||
|
||||
const load = useCallback(async (paginationParams?: any) => {
|
||||
if (!instanceId) return;
|
||||
|
|
@ -173,45 +185,20 @@ export const GraphicalEditorTemplatesPage: React.FC = () => {
|
|||
[mandateId, instanceId, navigate]
|
||||
);
|
||||
|
||||
const columns: ColumnConfig[] = useMemo(
|
||||
const _rawColumns: ColumnConfig[] = useMemo(
|
||||
() => [
|
||||
{ key: 'label', label: t('Vorlage'), type: 'string', width: 220, sortable: true },
|
||||
{
|
||||
key: 'templateScope',
|
||||
label: t('Bereich'),
|
||||
type: 'string',
|
||||
width: 100,
|
||||
formatter: (v: string) => scopeLabels[v as AutoTemplateScope] ?? v ?? '—',
|
||||
},
|
||||
{
|
||||
key: 'sharedReadOnly',
|
||||
label: t('Freigegeben'),
|
||||
type: 'boolean',
|
||||
width: 100,
|
||||
formatter: (v: boolean) =>
|
||||
v ? (
|
||||
<span style={{ color: 'var(--primary-color, #007bff)', fontWeight: 600 }}>{t('Ja')}</span>
|
||||
) : (
|
||||
<span style={{ color: 'var(--text-secondary, #666)' }}>{t('Nein')}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'sysCreatedBy',
|
||||
label: t('Erstellt von'),
|
||||
type: 'string',
|
||||
width: 140,
|
||||
fkSource: '/api/users/',
|
||||
fkDisplayField: 'username',
|
||||
},
|
||||
{
|
||||
key: 'sysCreatedAt',
|
||||
label: t('Erstellt'),
|
||||
type: 'number',
|
||||
width: 140,
|
||||
formatter: (v: number) => _formatTs(v),
|
||||
},
|
||||
{ key: 'label', label: t('Vorlage'), width: 220, sortable: true, filterable: true },
|
||||
{ key: 'templateScope', width: 100, sortable: true, filterable: true },
|
||||
{ key: 'sharedReadOnly', width: 100, sortable: true, filterable: true },
|
||||
{ key: 'sysCreatedBy', width: 140, sortable: true, filterable: true, displayField: 'sysCreatedByLabel' },
|
||||
{ key: 'sysCreatedAt', width: 140, sortable: true, filterable: true, formatter: (v: number) => _formatTs(v) },
|
||||
],
|
||||
[t, scopeLabels],
|
||||
[t],
|
||||
);
|
||||
|
||||
const columns = useMemo(
|
||||
() => resolveColumnTypes(_rawColumns, backendAttributes),
|
||||
[_rawColumns, backendAttributes],
|
||||
);
|
||||
|
||||
if (!instanceId) {
|
||||
|
|
@ -264,6 +251,7 @@ export const GraphicalEditorTemplatesPage: React.FC = () => {
|
|||
filterable={true}
|
||||
sortable={true}
|
||||
selectable={true}
|
||||
apiEndpoint={`/api/workflows/${instanceId}/templates`}
|
||||
actionButtons={[
|
||||
{
|
||||
type: 'edit',
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@
|
|||
* Actions: Edit, Delete, Aktivieren/Deaktivieren, Ausführen (nur bei manuellem Trigger).
|
||||
*/
|
||||
|
||||
import React, { useState, useCallback, useEffect, useRef } from 'react';
|
||||
import React, { useState, useCallback, useEffect, useRef, useMemo } from 'react';
|
||||
import { useNavigate, useParams } from 'react-router-dom';
|
||||
import { FaPlay, FaSync, FaCheck, FaBan, FaPen, FaFileImport, FaFileExport } from 'react-icons/fa';
|
||||
import { usePrompt } from '../../../hooks/usePrompt';
|
||||
|
|
@ -26,6 +26,9 @@ import {
|
|||
type Automation2Workflow,
|
||||
type WorkflowFileEnvelope,
|
||||
} from '../../../api/workflowApi';
|
||||
import { fetchAttributes } from '../../../api/attributesApi';
|
||||
import type { AttributeDefinition } from '../../../api/attributesApi';
|
||||
import { resolveColumnTypes } from '../../../utils/columnTypeResolver';
|
||||
import { useToast } from '../../../contexts/ToastContext';
|
||||
import { formatUnixTimestamp } from '../../../utils/time';
|
||||
import styles from '../../../pages/admin/Admin.module.css';
|
||||
|
|
@ -64,6 +67,15 @@ export const GraphicalEditorWorkflowsPage: React.FC = () => {
|
|||
const [paginationMeta, setPaginationMeta] = useState<any>(null);
|
||||
const [importing, setImporting] = useState(false);
|
||||
const importFileInputRef = useRef<HTMLInputElement>(null);
|
||||
const [backendAttributes, setBackendAttributes] = useState<AttributeDefinition[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchAttributes(request, 'Automation2WorkflowView')
|
||||
.then(setBackendAttributes)
|
||||
.catch((err) => {
|
||||
console.error('[graphicalEditor] fetchAttributes Automation2WorkflowView failed', err);
|
||||
});
|
||||
}, [request]);
|
||||
|
||||
const load = useCallback(async (paginationParams?: any) => {
|
||||
if (!instanceId) return;
|
||||
|
|
@ -251,64 +263,51 @@ export const GraphicalEditorWorkflowsPage: React.FC = () => {
|
|||
[instanceId, request, showSuccess, showError, load, t],
|
||||
);
|
||||
|
||||
const columns: ColumnConfig[] = [
|
||||
{ key: 'label', label: t('Workflow'), type: 'string', width: 200, sortable: true },
|
||||
{
|
||||
key: 'active',
|
||||
label: t('Aktiv (Spalte)'),
|
||||
type: 'boolean',
|
||||
width: 80,
|
||||
formatter: (value: boolean) =>
|
||||
value !== false ? (
|
||||
<span style={{ color: 'var(--success-color, #28a745)', fontWeight: 600 }}>Ja</span>
|
||||
) : (
|
||||
<span style={{ color: 'var(--text-secondary, #666)' }}>Nein</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'isRunning',
|
||||
label: t('läuft'),
|
||||
type: 'boolean',
|
||||
width: 80,
|
||||
formatter: (value: boolean) =>
|
||||
value ? (
|
||||
<span style={{ color: 'var(--success-color, #28a745)', fontWeight: 600 }}>{t('Ja')}</span>
|
||||
) : (
|
||||
<span style={{ color: 'var(--text-secondary, #666)' }}>Nein</span>
|
||||
),
|
||||
},
|
||||
const _rawColumns: ColumnConfig[] = useMemo(() => [
|
||||
{ key: 'label', label: t('Workflow'), width: 200, sortable: true, filterable: true },
|
||||
{ key: 'active', width: 80, sortable: true, filterable: true },
|
||||
{ key: 'isRunning', width: 80, sortable: true, filterable: true },
|
||||
{
|
||||
key: 'stuckAtNodeLabel',
|
||||
label: t('steht bei'),
|
||||
type: 'string',
|
||||
width: 160,
|
||||
sortable: false,
|
||||
filterable: false,
|
||||
formatter: (value: string, row: Automation2Workflow) =>
|
||||
row.isRunning && (value || row.stuckAtNodeId)
|
||||
? value || row.stuckAtNodeId || '—'
|
||||
: '—',
|
||||
},
|
||||
{
|
||||
key: 'createdAt',
|
||||
key: 'sysCreatedAt',
|
||||
label: t('Erstellt'),
|
||||
type: 'number',
|
||||
width: 140,
|
||||
sortable: true,
|
||||
filterable: true,
|
||||
formatter: (v: number) => formatTs(v),
|
||||
},
|
||||
{
|
||||
key: 'lastStartedAt',
|
||||
label: t('zuletzt gestartet'),
|
||||
type: 'number',
|
||||
width: 160,
|
||||
sortable: true,
|
||||
filterable: true,
|
||||
formatter: (v: number) => formatTs(v),
|
||||
},
|
||||
{
|
||||
key: 'runCount',
|
||||
label: t('Läufe'),
|
||||
type: 'number',
|
||||
width: 80,
|
||||
sortable: true,
|
||||
filterable: true,
|
||||
formatter: (v: number) => (v != null ? String(v) : '0'),
|
||||
},
|
||||
];
|
||||
], [t]);
|
||||
|
||||
const columns = useMemo(
|
||||
() => resolveColumnTypes(_rawColumns, backendAttributes),
|
||||
[_rawColumns, backendAttributes],
|
||||
);
|
||||
|
||||
const hookData = {
|
||||
refetch: load,
|
||||
|
|
@ -381,6 +380,7 @@ export const GraphicalEditorWorkflowsPage: React.FC = () => {
|
|||
filterable={true}
|
||||
sortable={true}
|
||||
selectable={true}
|
||||
apiEndpoint={`/api/workflows/${instanceId}/workflows`}
|
||||
actionButtons={[
|
||||
{
|
||||
type: 'edit',
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ import { FaSync } from 'react-icons/fa';
|
|||
import styles from '../../admin/Admin.module.css';
|
||||
|
||||
import { useLanguage } from '../../../providers/language/LanguageContext';
|
||||
import { resolveColumnTypes } from '../../../utils/columnTypeResolver';
|
||||
|
||||
export const RealEstateParcelsView: React.FC = () => {
|
||||
const { t } = useLanguage();
|
||||
|
|
@ -54,17 +55,18 @@ export const RealEstateParcelsView: React.FC = () => {
|
|||
}, [instanceId, refetch]);
|
||||
|
||||
const columns = useMemo(() => {
|
||||
return (attributes || []).map(attr => ({
|
||||
const raw = (attributes || []).map(attr => ({
|
||||
key: attr.name,
|
||||
label: attr.label || attr.name,
|
||||
type: attr.type as 'string' | 'number' | 'date' | 'boolean',
|
||||
sortable: attr.sortable !== false,
|
||||
filterable: attr.filterable !== false,
|
||||
searchable: attr.searchable !== false,
|
||||
width: attr.width || 150,
|
||||
minWidth: attr.minWidth || 100,
|
||||
maxWidth: attr.maxWidth || 400,
|
||||
displayField: (attr as any).displayField,
|
||||
}));
|
||||
return resolveColumnTypes(raw, attributes || []);
|
||||
}, [attributes]);
|
||||
|
||||
const canCreate = permissions?.create !== 'n';
|
||||
|
|
@ -177,6 +179,7 @@ export const RealEstateParcelsView: React.FC = () => {
|
|||
filterable={true}
|
||||
sortable={true}
|
||||
selectable={true}
|
||||
apiEndpoint={instanceId ? `/api/realestate/${instanceId}/parcels` : undefined}
|
||||
actionButtons={[
|
||||
...(canUpdate
|
||||
? [
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ import { FaSync } from 'react-icons/fa';
|
|||
import styles from '../../admin/Admin.module.css';
|
||||
|
||||
import { useLanguage } from '../../../providers/language/LanguageContext';
|
||||
import { resolveColumnTypes } from '../../../utils/columnTypeResolver';
|
||||
|
||||
export const RealEstateProjectsView: React.FC = () => {
|
||||
const { t } = useLanguage();
|
||||
|
|
@ -52,17 +53,18 @@ export const RealEstateProjectsView: React.FC = () => {
|
|||
}, [instanceId, refetch]);
|
||||
|
||||
const columns = useMemo(() => {
|
||||
return (attributes || []).map(attr => ({
|
||||
const raw = (attributes || []).map(attr => ({
|
||||
key: attr.name,
|
||||
label: attr.label || attr.name,
|
||||
type: (attr.type || 'string') as 'string' | 'number' | 'date' | 'boolean',
|
||||
sortable: attr.sortable !== false,
|
||||
filterable: attr.filterable !== false,
|
||||
searchable: attr.searchable !== false,
|
||||
width: attr.width || 150,
|
||||
minWidth: attr.minWidth || 100,
|
||||
maxWidth: attr.maxWidth || 400,
|
||||
displayField: (attr as any).displayField,
|
||||
}));
|
||||
return resolveColumnTypes(raw, attributes || []);
|
||||
}, [attributes]);
|
||||
|
||||
const canCreate = permissions?.create !== 'n';
|
||||
|
|
@ -163,6 +165,7 @@ export const RealEstateProjectsView: React.FC = () => {
|
|||
filterable={true}
|
||||
sortable={true}
|
||||
selectable={true}
|
||||
apiEndpoint={instanceId ? `/api/realestate/${instanceId}/projects` : undefined}
|
||||
actionButtons={[
|
||||
...(canUpdate ? [{ type: 'edit' as const, onAction: handleEditClick, title: t('Bearbeiten') }] : []),
|
||||
...(canDelete ? [{ type: 'delete' as const, title: t('Löschen'), loading: (row: RealEstateProject) => deletingItems.has(row.id) }] : []),
|
||||
|
|
|
|||
|
|
@ -273,6 +273,7 @@ export const RedmineStatsView: React.FC = () => {
|
|||
direction: 'past',
|
||||
defaultPresetKind: 'thisQuarter',
|
||||
enabledPresets: [
|
||||
'allTime',
|
||||
'thisMonth', 'lastMonth', 'thisQuarter', 'lastQuarter',
|
||||
'ytd', 'lastYear', 'last12Months', 'lastN', 'custom',
|
||||
],
|
||||
|
|
@ -332,8 +333,15 @@ export const RedmineStatsView: React.FC = () => {
|
|||
|
||||
const _handleFilterChange = useCallback((filterState: ReportFilterState) => {
|
||||
if (filterState.periodValue) {
|
||||
setDateFrom(filterState.periodValue.fromDate);
|
||||
setDateTo(filterState.periodValue.toDate);
|
||||
// "Alle" = no date filter. Drop the sentinel range so the backend
|
||||
// aggregates over the full history instead of clamping to 1970--2999.
|
||||
if (filterState.periodValue.preset.kind === 'allTime') {
|
||||
setDateFrom(undefined);
|
||||
setDateTo(undefined);
|
||||
} else {
|
||||
setDateFrom(filterState.periodValue.fromDate);
|
||||
setDateTo(filterState.periodValue.toDate);
|
||||
}
|
||||
} else if (filterState.dateRange) {
|
||||
setDateFrom(toIsoDate(filterState.dateRange.from));
|
||||
setDateTo(toIsoDate(filterState.dateRange.to));
|
||||
|
|
|
|||
|
|
@ -416,6 +416,366 @@
|
|||
height: 100%;
|
||||
}
|
||||
|
||||
/* ----- Session Layout (UDB Sidebar + Main) ------------------------------- */
|
||||
|
||||
.sessionLayout {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.sessionMain {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.udbSidebar {
|
||||
width: 280px;
|
||||
min-width: 280px;
|
||||
border: 1px solid var(--border-color, #e0e0e0);
|
||||
border-radius: 8px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: var(--bg-card, #fff);
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
transition: width 0.2s, min-width 0.2s;
|
||||
}
|
||||
|
||||
.udbSidebarCollapsed {
|
||||
width: 36px;
|
||||
min-width: 36px;
|
||||
}
|
||||
|
||||
.udbToggle {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
right: 4px;
|
||||
z-index: 2;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
padding: 0;
|
||||
border: 1px solid var(--border-color, #ddd);
|
||||
border-radius: 4px;
|
||||
background: var(--bg-card, #fff);
|
||||
cursor: pointer;
|
||||
font-size: 0.65rem;
|
||||
color: var(--text-secondary, #888);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.udbToggle:hover {
|
||||
background: var(--bg-hover, #f5f5f5);
|
||||
color: var(--primary-color, #F25843);
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.sessionLayout {
|
||||
flex-direction: column;
|
||||
}
|
||||
.udbSidebar {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
max-height: 220px;
|
||||
}
|
||||
.udbSidebarCollapsed {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* ----- Director Prompt Panel --------------------------------------------- */
|
||||
|
||||
.directorPanel {
|
||||
background: var(--surface-color, #fff);
|
||||
border: 1px solid var(--border-color, #e0e0e0);
|
||||
border-radius: 8px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
transition: outline-color 0.15s, background 0.15s;
|
||||
}
|
||||
|
||||
.directorPanelDragOver {
|
||||
outline: 2px dashed var(--primary-color, #F25843);
|
||||
outline-offset: -4px;
|
||||
background: var(--primary-dark-bg, rgba(242, 88, 67, 0.06));
|
||||
}
|
||||
|
||||
.botStatusDot {
|
||||
display: inline-block;
|
||||
width: 9px;
|
||||
height: 9px;
|
||||
border-radius: 50%;
|
||||
margin-left: 0.25rem;
|
||||
}
|
||||
|
||||
.botStatusDotLive {
|
||||
background: #15803d;
|
||||
box-shadow: 0 0 0 2px rgba(21, 128, 61, 0.18);
|
||||
}
|
||||
|
||||
.botStatusDotIdle {
|
||||
background: #f59e0b;
|
||||
box-shadow: 0 0 0 2px rgba(245, 158, 11, 0.18);
|
||||
animation: directorPulse 1.6s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes directorPulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.45; }
|
||||
}
|
||||
|
||||
.directorAttachBtn {
|
||||
border: 1px solid var(--border-color, #ddd);
|
||||
background: var(--bg-card, #fff);
|
||||
border-radius: 6px;
|
||||
padding: 0.25rem 0.6rem;
|
||||
font-size: 0.75rem;
|
||||
cursor: pointer;
|
||||
color: var(--text-secondary, #666);
|
||||
}
|
||||
|
||||
.directorAttachBtn:hover:not(:disabled) {
|
||||
border-color: var(--primary-color, #F25843);
|
||||
color: var(--primary-color, #F25843);
|
||||
}
|
||||
|
||||
.directorAttachBtn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.directorHint {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-secondary, #888);
|
||||
background: var(--surface-alt, #fafafa);
|
||||
padding: 0.4rem 0.6rem;
|
||||
border-radius: 6px;
|
||||
border: 1px dashed var(--border-color, #ddd);
|
||||
}
|
||||
|
||||
.directorHeader {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0.5rem 1rem;
|
||||
border-bottom: 1px solid var(--border-color, #e0e0e0);
|
||||
background: var(--surface-alt, #fafafa);
|
||||
}
|
||||
|
||||
.directorHeaderLeft {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.directorTitle {
|
||||
margin: 0;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.directorBadge {
|
||||
font-size: 0.7rem;
|
||||
padding: 2px 6px;
|
||||
border-radius: 10px;
|
||||
background: var(--primary-color, #F25843);
|
||||
color: #fff;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.directorBody {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
padding: 0.75rem 1rem;
|
||||
}
|
||||
|
||||
.directorTextarea {
|
||||
width: 100%;
|
||||
min-height: 70px;
|
||||
max-height: 200px;
|
||||
resize: vertical;
|
||||
padding: 0.5rem 0.75rem;
|
||||
border: 1px solid var(--border-color, #ddd);
|
||||
border-radius: 6px;
|
||||
font-family: inherit;
|
||||
font-size: 0.9rem;
|
||||
background: var(--bg-card, #fff);
|
||||
color: var(--text-primary, #333);
|
||||
}
|
||||
|
||||
.directorTextarea:focus {
|
||||
outline: none;
|
||||
border-color: var(--primary-color, #F25843);
|
||||
}
|
||||
|
||||
.directorRow {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.directorChips {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.4rem;
|
||||
padding: 0.25rem 0;
|
||||
}
|
||||
|
||||
.directorChip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
padding: 0.2rem 0.5rem;
|
||||
background: var(--surface-alt, #f0f4f8);
|
||||
border: 1px solid var(--border-color, #ddd);
|
||||
border-radius: 12px;
|
||||
font-size: 0.75rem;
|
||||
max-width: 180px;
|
||||
}
|
||||
|
||||
.directorChipName {
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.directorChipRemove {
|
||||
border: none;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
color: var(--text-secondary, #888);
|
||||
font-size: 0.85rem;
|
||||
line-height: 1;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.directorChipRemove:hover {
|
||||
color: var(--primary-color, #F25843);
|
||||
}
|
||||
|
||||
.directorActions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.directorMeta {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
font-size: 0.72rem;
|
||||
color: var(--text-secondary, #888);
|
||||
}
|
||||
|
||||
.directorSubmit {
|
||||
padding: 0.4rem 0.9rem;
|
||||
background: var(--primary-color, #F25843);
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.directorSubmit:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.directorModeToggle {
|
||||
display: inline-flex;
|
||||
border: 1px solid var(--border-color, #ddd);
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.directorModeButton {
|
||||
border: none;
|
||||
background: var(--bg-card, #fff);
|
||||
padding: 0.25rem 0.6rem;
|
||||
font-size: 0.75rem;
|
||||
cursor: pointer;
|
||||
color: var(--text-secondary, #666);
|
||||
}
|
||||
|
||||
.directorModeButtonActive {
|
||||
background: var(--primary-color, #F25843);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.directorHistory {
|
||||
border-top: 1px dashed var(--border-color, #e0e0e0);
|
||||
padding: 0.5rem 1rem;
|
||||
max-height: 180px;
|
||||
overflow-y: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
|
||||
.directorHistoryItem {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.2rem;
|
||||
padding: 0.4rem 0.5rem;
|
||||
border: 1px solid var(--border-color, #eee);
|
||||
border-radius: 6px;
|
||||
background: var(--surface-alt, #fafafa);
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
|
||||
.directorHistoryHead {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
color: var(--text-secondary, #666);
|
||||
font-size: 0.7rem;
|
||||
}
|
||||
|
||||
.directorHistoryText {
|
||||
color: var(--text-primary, #333);
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.directorStatus {
|
||||
font-size: 0.7rem;
|
||||
font-weight: 600;
|
||||
padding: 1px 6px;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.directorStatusQueued { background: #e6efff; color: #1d4ed8; }
|
||||
.directorStatusRunning { background: #fff7e0; color: #b45309; }
|
||||
.directorStatusSucceeded { background: #e6f7ec; color: #15803d; }
|
||||
.directorStatusFailed { background: #fde2e1; color: #b91c1c; }
|
||||
.directorStatusConsumed { background: #eee; color: #555; }
|
||||
|
||||
.directorRemoveBtn {
|
||||
border: none;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
color: var(--text-secondary, #888);
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.directorRemoveBtn:hover {
|
||||
color: var(--primary-color, #F25843);
|
||||
}
|
||||
|
||||
.sessionViewHeader {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
|
|
@ -579,6 +939,35 @@
|
|||
max-width: 720px;
|
||||
}
|
||||
|
||||
/* Tabs */
|
||||
.settingsTabs {
|
||||
display: flex;
|
||||
gap: 0.25rem;
|
||||
border-bottom: 1px solid var(--border-color, #e0e0e0);
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.settingsTab {
|
||||
padding: 0.6rem 1.1rem;
|
||||
background: transparent;
|
||||
color: var(--text-secondary, #666);
|
||||
border: none;
|
||||
border-bottom: 2px solid transparent;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: color 0.15s ease, border-color 0.15s ease;
|
||||
}
|
||||
|
||||
.settingsTab:hover {
|
||||
color: var(--text-color, #333);
|
||||
}
|
||||
|
||||
.settingsTabActive {
|
||||
color: var(--primary-color, #4A90D9);
|
||||
border-bottom-color: var(--primary-color, #4A90D9);
|
||||
}
|
||||
|
||||
.settingsCard {
|
||||
background: var(--surface-color, #fff);
|
||||
border: 1px solid var(--border-color, #e0e0e0);
|
||||
|
|
|
|||
|
|
@ -2,8 +2,23 @@ import React, { useState, useEffect, useRef, useCallback, useMemo } from 'react'
|
|||
import { useSearchParams } from 'react-router-dom';
|
||||
import { useCurrentInstance } from '../../../hooks/useCurrentInstance';
|
||||
import * as teamsbotApi from '../../../api/teamsbotApi';
|
||||
import type { TeamsbotSession, TeamsbotTranscript, TeamsbotBotResponse, TeamsbotSSEEvent, ScreenshotInfo } from '../../../api/teamsbotApi';
|
||||
import type {
|
||||
TeamsbotSession,
|
||||
TeamsbotTranscript,
|
||||
TeamsbotBotResponse,
|
||||
TeamsbotSSEEvent,
|
||||
ScreenshotInfo,
|
||||
DirectorPrompt,
|
||||
DirectorPromptMode,
|
||||
} from '../../../api/teamsbotApi';
|
||||
import {
|
||||
DIRECTOR_PROMPT_TEXT_LIMIT,
|
||||
DIRECTOR_PROMPT_FILE_LIMIT,
|
||||
} from '../../../api/teamsbotApi';
|
||||
import { getUserDataCache } from '../../../utils/userCache';
|
||||
import { UnifiedDataBar } from '../../../components/UnifiedDataBar';
|
||||
import type { UdbContext, UdbTab } from '../../../components/UnifiedDataBar';
|
||||
import { useFileContext } from '../../../contexts/FileContext';
|
||||
import styles from './Teamsbot.module.css';
|
||||
|
||||
import { useLanguage } from '../../../providers/language/LanguageContext';
|
||||
|
|
@ -41,6 +56,32 @@ export const TeamsbotSessionView: React.FC = () => {
|
|||
timestamp: string;
|
||||
}>>([]);
|
||||
|
||||
// Director Prompt panel state
|
||||
const [directorPrompts, setDirectorPrompts] = useState<DirectorPrompt[]>([]);
|
||||
const [directorText, setDirectorText] = useState('');
|
||||
const [directorMode, setDirectorMode] = useState<DirectorPromptMode>('oneShot');
|
||||
const [directorFiles, setDirectorFiles] = useState<Array<{ id: string; name: string }>>([]);
|
||||
const [directorSubmitting, setDirectorSubmitting] = useState(false);
|
||||
const [directorError, setDirectorError] = useState<string | null>(null);
|
||||
const [directorDragOver, setDirectorDragOver] = useState(false);
|
||||
const [directorUploading, setDirectorUploading] = useState(false);
|
||||
const directorDragCounterRef = useRef(0);
|
||||
const directorFileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// Bot WebSocket connection state (separate from session.status: the session
|
||||
// can be 'active' before the bot has actually opened its WebSocket back to
|
||||
// the gateway. Director prompts can only be processed once botConnected=true.)
|
||||
const [botConnected, setBotConnected] = useState(false);
|
||||
|
||||
// UDB Sidebar state
|
||||
const [udbCollapsed, setUdbCollapsed] = useState(false);
|
||||
const [udbTab, setUdbTab] = useState<UdbTab>('files');
|
||||
const _udbContext: UdbContext | null = instanceId
|
||||
? { instanceId, featureInstanceId: instanceId }
|
||||
: null;
|
||||
|
||||
const fileCtx = useFileContext();
|
||||
|
||||
const transcriptEndRef = useRef<HTMLDivElement>(null);
|
||||
const eventSourceRef = useRef<EventSource | null>(null);
|
||||
|
||||
|
|
@ -98,14 +139,38 @@ export const TeamsbotSessionView: React.FC = () => {
|
|||
_loadSession();
|
||||
}, [_loadSession]);
|
||||
|
||||
// SSE Live Stream - connect once per session, don't re-create on status changes
|
||||
const sseSessionRef = useRef<string | null>(null);
|
||||
const sessionStatus = session?.status;
|
||||
// Load director prompt history when session changes
|
||||
useEffect(() => {
|
||||
if (!instanceId || !sessionId || !sessionStatus) return;
|
||||
if (!['active', 'joining', 'pending'].includes(sessionStatus)) return;
|
||||
if (!instanceId || !sessionId) return;
|
||||
let cancelled = false;
|
||||
teamsbotApi
|
||||
.listDirectorPrompts(instanceId, sessionId)
|
||||
.then((res) => {
|
||||
if (!cancelled) setDirectorPrompts(res.prompts || []);
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setDirectorPrompts([]);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [instanceId, sessionId]);
|
||||
|
||||
// SSE Live Stream - connect once per session, don't re-create on status changes.
|
||||
// We deliberately depend ONLY on (instanceId, sessionId), not on session.status,
|
||||
// so transient status transitions (pending -> joining -> active) don't tear down
|
||||
// and rebuild the EventSource (which used to flicker botConnected and spawn
|
||||
// multiple parallel /stream connections to the gateway).
|
||||
const sseSessionRef = useRef<string | null>(null);
|
||||
const sessionStatusRef = useRef<string | undefined>(session?.status);
|
||||
sessionStatusRef.current = session?.status;
|
||||
useEffect(() => {
|
||||
if (!instanceId || !sessionId) return;
|
||||
// Avoid reconnecting if already streaming this session
|
||||
if (sseSessionRef.current === sessionId && eventSourceRef.current) return;
|
||||
// Don't open a stream for sessions that are known to already be terminal.
|
||||
const initialStatus = sessionStatusRef.current;
|
||||
if (initialStatus && !['active', 'joining', 'pending'].includes(initialStatus)) return;
|
||||
|
||||
eventSourceRef.current?.close();
|
||||
sseSessionRef.current = sessionId;
|
||||
|
|
@ -200,6 +265,34 @@ export const TeamsbotSessionView: React.FC = () => {
|
|||
break;
|
||||
}
|
||||
|
||||
case 'botConnectionState': {
|
||||
const data = sseEvent.data || {};
|
||||
setBotConnected(Boolean(data.connected));
|
||||
_dlog('BOT-WS', data.connected ? 'connected' : 'disconnected');
|
||||
break;
|
||||
}
|
||||
|
||||
case 'directorPrompt': {
|
||||
const prompt = sseEvent.data as DirectorPrompt | undefined;
|
||||
if (!prompt || !prompt.id) break;
|
||||
setDirectorPrompts((prev) => {
|
||||
const idx = prev.findIndex((p) => p.id === prompt.id);
|
||||
if (idx >= 0) {
|
||||
const updated = [...prev];
|
||||
updated[idx] = { ...updated[idx], ...prompt };
|
||||
return updated;
|
||||
}
|
||||
return [prompt, ...prev];
|
||||
});
|
||||
break;
|
||||
}
|
||||
|
||||
case 'agentRun': {
|
||||
const data = sseEvent.data || {};
|
||||
_dlog('AGENT', `${data.status || ''} ${data.reason || ''}`.trim());
|
||||
break;
|
||||
}
|
||||
|
||||
case 'error': {
|
||||
const errData = sseEvent.data || {};
|
||||
const errMsg = errData.message || t('Unbekannter Fehler');
|
||||
|
|
@ -229,8 +322,10 @@ export const TeamsbotSessionView: React.FC = () => {
|
|||
eventSourceRef.current = null;
|
||||
sseSessionRef.current = null;
|
||||
setIsLive(false);
|
||||
setBotConnected(false);
|
||||
};
|
||||
}, [instanceId, sessionId, sessionStatus, _dlog, t]);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [instanceId, sessionId]);
|
||||
|
||||
// Polling fallback: refresh session data every 5s when SSE is not connected
|
||||
const pollRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
|
|
@ -274,6 +369,193 @@ export const TeamsbotSessionView: React.FC = () => {
|
|||
}
|
||||
};
|
||||
|
||||
const _addDirectorFile = useCallback((fileId: string, fileName?: string) => {
|
||||
setDirectorFiles((prev) => {
|
||||
if (prev.some((f) => f.id === fileId)) return prev;
|
||||
if (prev.length >= DIRECTOR_PROMPT_FILE_LIMIT) {
|
||||
setDirectorError(
|
||||
t('Maximal {n} Dateien pro Regieanweisung.', { n: String(DIRECTOR_PROMPT_FILE_LIMIT) }),
|
||||
);
|
||||
return prev;
|
||||
}
|
||||
setDirectorError(null);
|
||||
return [...prev, { id: fileId, name: fileName || fileId }];
|
||||
});
|
||||
}, [t]);
|
||||
|
||||
const _handleUdbFileSelect = _addDirectorFile;
|
||||
|
||||
const _removeDirectorFile = (fileId: string) => {
|
||||
setDirectorFiles((prev) => prev.filter((f) => f.id !== fileId));
|
||||
};
|
||||
|
||||
const _uploadAndAttachDirectorFile = useCallback(async (file: File) => {
|
||||
if (!fileCtx?.handleFileUpload) return;
|
||||
setDirectorUploading(true);
|
||||
setDirectorError(null);
|
||||
try {
|
||||
const result = await fileCtx.handleFileUpload(file);
|
||||
if (result?.success) {
|
||||
const data: any = (result.fileData as any)?.file || result.fileData;
|
||||
const id = data?.id || (result.fileData as any)?.id;
|
||||
if (id) {
|
||||
_addDirectorFile(id, data?.fileName || file.name);
|
||||
} else {
|
||||
setDirectorError(t('Upload erfolgreich, aber keine Datei-ID erhalten.'));
|
||||
}
|
||||
} else {
|
||||
setDirectorError(result?.error || t('Upload fehlgeschlagen.'));
|
||||
}
|
||||
} catch (err: any) {
|
||||
setDirectorError(err?.message || t('Upload fehlgeschlagen.'));
|
||||
} finally {
|
||||
setDirectorUploading(false);
|
||||
}
|
||||
}, [fileCtx, _addDirectorFile, t]);
|
||||
|
||||
const _onDirectorDragEnter = useCallback((e: React.DragEvent) => {
|
||||
if (
|
||||
e.dataTransfer.types.includes('Files') ||
|
||||
e.dataTransfer.types.includes('application/file-id') ||
|
||||
e.dataTransfer.types.includes('application/file-ids') ||
|
||||
e.dataTransfer.types.includes('application/tree-items')
|
||||
) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
directorDragCounterRef.current += 1;
|
||||
setDirectorDragOver(true);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const _onDirectorDragOver = useCallback((e: React.DragEvent) => {
|
||||
if (
|
||||
e.dataTransfer.types.includes('Files') ||
|
||||
e.dataTransfer.types.includes('application/file-id') ||
|
||||
e.dataTransfer.types.includes('application/file-ids') ||
|
||||
e.dataTransfer.types.includes('application/tree-items')
|
||||
) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
e.dataTransfer.dropEffect = 'copy';
|
||||
}
|
||||
}, []);
|
||||
|
||||
const _onDirectorDragLeave = useCallback((e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
directorDragCounterRef.current = Math.max(0, directorDragCounterRef.current - 1);
|
||||
if (directorDragCounterRef.current === 0) setDirectorDragOver(false);
|
||||
}, []);
|
||||
|
||||
const _onDirectorDrop = useCallback(async (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
directorDragCounterRef.current = 0;
|
||||
setDirectorDragOver(false);
|
||||
|
||||
const fileIdsJson = e.dataTransfer.getData('application/file-ids');
|
||||
if (fileIdsJson) {
|
||||
try {
|
||||
const ids: string[] = JSON.parse(fileIdsJson);
|
||||
ids.forEach((id) => _addDirectorFile(id));
|
||||
} catch { /* ignore malformed */ }
|
||||
return;
|
||||
}
|
||||
|
||||
const singleFileId = e.dataTransfer.getData('application/file-id');
|
||||
if (singleFileId) {
|
||||
const label = e.dataTransfer.getData('text/plain');
|
||||
_addDirectorFile(singleFileId, label || undefined);
|
||||
return;
|
||||
}
|
||||
|
||||
const treeItemsJson = e.dataTransfer.getData('application/tree-items');
|
||||
if (treeItemsJson) {
|
||||
try {
|
||||
const items: Array<{ id: string; type: 'file' | 'folder'; name: string }> = JSON.parse(treeItemsJson);
|
||||
items.filter((it) => it.type === 'file').forEach((it) => _addDirectorFile(it.id, it.name));
|
||||
} catch { /* ignore malformed */ }
|
||||
return;
|
||||
}
|
||||
|
||||
if (e.dataTransfer.files && e.dataTransfer.files.length > 0) {
|
||||
for (const file of Array.from(e.dataTransfer.files)) {
|
||||
if (directorFiles.length >= DIRECTOR_PROMPT_FILE_LIMIT) {
|
||||
setDirectorError(
|
||||
t('Maximal {n} Dateien pro Regieanweisung.', { n: String(DIRECTOR_PROMPT_FILE_LIMIT) }),
|
||||
);
|
||||
break;
|
||||
}
|
||||
await _uploadAndAttachDirectorFile(file);
|
||||
}
|
||||
}
|
||||
}, [_addDirectorFile, _uploadAndAttachDirectorFile, directorFiles.length, t]);
|
||||
|
||||
const _onDirectorFileInput = useCallback(async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
if (!e.target.files || e.target.files.length === 0) return;
|
||||
for (const file of Array.from(e.target.files)) {
|
||||
if (directorFiles.length >= DIRECTOR_PROMPT_FILE_LIMIT) break;
|
||||
await _uploadAndAttachDirectorFile(file);
|
||||
}
|
||||
e.target.value = '';
|
||||
}, [_uploadAndAttachDirectorFile, directorFiles.length]);
|
||||
|
||||
const _submitDirectorPrompt = async () => {
|
||||
if (!instanceId || !sessionId) return;
|
||||
const trimmed = directorText.trim();
|
||||
if (!trimmed) {
|
||||
setDirectorError(t('Bitte gib eine Anweisung ein.'));
|
||||
return;
|
||||
}
|
||||
if (trimmed.length > DIRECTOR_PROMPT_TEXT_LIMIT) {
|
||||
setDirectorError(
|
||||
t('Text zu lang (max. {n} Zeichen).', { n: String(DIRECTOR_PROMPT_TEXT_LIMIT) }),
|
||||
);
|
||||
return;
|
||||
}
|
||||
setDirectorSubmitting(true);
|
||||
setDirectorError(null);
|
||||
try {
|
||||
const res = await teamsbotApi.submitDirectorPrompt(instanceId, sessionId, {
|
||||
text: trimmed,
|
||||
mode: directorMode,
|
||||
fileIds: directorFiles.map((f) => f.id),
|
||||
});
|
||||
if (res.prompt) {
|
||||
setDirectorPrompts((prev) => {
|
||||
const idx = prev.findIndex((p) => p.id === res.prompt.id);
|
||||
if (idx >= 0) {
|
||||
const next = [...prev];
|
||||
next[idx] = res.prompt;
|
||||
return next;
|
||||
}
|
||||
return [res.prompt, ...prev];
|
||||
});
|
||||
}
|
||||
setDirectorText('');
|
||||
setDirectorFiles([]);
|
||||
} catch (err: any) {
|
||||
setDirectorError(err?.response?.data?.detail || err?.message || t('Senden fehlgeschlagen.'));
|
||||
} finally {
|
||||
setDirectorSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const _removeDirectorPrompt = async (promptId: string) => {
|
||||
if (!instanceId || !sessionId) return;
|
||||
try {
|
||||
await teamsbotApi.deleteDirectorPrompt(instanceId, sessionId, promptId);
|
||||
setDirectorPrompts((prev) => prev.filter((p) => p.id !== promptId));
|
||||
} catch (err: any) {
|
||||
setDirectorError(err?.message || t('Entfernen fehlgeschlagen.'));
|
||||
}
|
||||
};
|
||||
|
||||
const activePersistentCount = useMemo(
|
||||
() => directorPrompts.filter((p) => p.mode === 'persistent' && p.status !== 'consumed').length,
|
||||
[directorPrompts],
|
||||
);
|
||||
|
||||
const _getSpeakerColor = (speaker: string) => {
|
||||
const colors = ['#4A90D9', '#D94A4A', '#4AD99A', '#D9A84A', '#9A4AD9', '#4AD9D9'];
|
||||
let hash = 0;
|
||||
|
|
@ -341,6 +623,227 @@ export const TeamsbotSessionView: React.FC = () => {
|
|||
|
||||
{error && <div className={styles.errorBanner}>{error}</div>}
|
||||
|
||||
{/* Layout: UDB Sidebar + Main */}
|
||||
<div className={styles.sessionLayout}>
|
||||
{/* UDB Sidebar (Files / Sources) */}
|
||||
{_udbContext && (
|
||||
<div className={`${styles.udbSidebar} ${udbCollapsed ? styles.udbSidebarCollapsed : ''}`}>
|
||||
<button
|
||||
className={styles.udbToggle}
|
||||
onClick={() => setUdbCollapsed((v) => !v)}
|
||||
title={udbCollapsed ? t('Seitenleiste einblenden') : t('Seitenleiste ausblenden')}
|
||||
>
|
||||
{udbCollapsed ? '\u25B6' : '\u25C0'}
|
||||
</button>
|
||||
{!udbCollapsed && (
|
||||
<UnifiedDataBar
|
||||
context={_udbContext}
|
||||
activeTab={udbTab}
|
||||
onTabChange={setUdbTab}
|
||||
hideTabs={['chats']}
|
||||
onFileSelect={_handleUdbFileSelect}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Main column */}
|
||||
<div className={styles.sessionMain}>
|
||||
|
||||
{/* Director Prompt Panel (private operator instructions) */}
|
||||
{['active', 'joining', 'pending'].includes(session.status) && (
|
||||
<div
|
||||
className={`${styles.directorPanel} ${directorDragOver ? styles.directorPanelDragOver : ''}`}
|
||||
onDragEnter={_onDirectorDragEnter}
|
||||
onDragOver={_onDirectorDragOver}
|
||||
onDragLeave={_onDirectorDragLeave}
|
||||
onDrop={_onDirectorDrop}
|
||||
>
|
||||
{(() => {
|
||||
const sStatus = session?.status;
|
||||
const isSessionLaunching = !!sStatus && ['pending', 'joining'].includes(sStatus);
|
||||
const isSessionActive = sStatus === 'active';
|
||||
// Bot has joined the meeting (session active) but the WebSocket back
|
||||
// to the gateway is missing -> usually means the browser-bot service
|
||||
// can't reach this gateway (e.g. localhost gateway + remote bot, or
|
||||
// bot behind firewall). Audio + transcripts won't flow.
|
||||
const isBotUnreachable = isSessionActive && !botConnected;
|
||||
const statusLabel = botConnected
|
||||
? t('Bot live')
|
||||
: isBotUnreachable
|
||||
? t('Bot ist im Meeting, aber nicht mit dem Gateway verbunden')
|
||||
: isSessionLaunching
|
||||
? t('Bot startet ...')
|
||||
: t('Keine aktive Session');
|
||||
const statusTitle = botConnected
|
||||
? t('Bot ist live im Meeting verbunden und liefert Transkripte')
|
||||
: isBotUnreachable
|
||||
? t('Der Browser-Bot hat den WebSocket nicht zurueck zum Gateway geoeffnet. Pruefe TEAMSBOT_BROWSER_BOT_URL und APP_API_URL: bei lokalem Gateway muss der Bot ebenfalls lokal laufen oder das Gateway ueber einen Tunnel erreichbar sein.')
|
||||
: isSessionLaunching
|
||||
? t('Bot tritt dem Meeting bei und oeffnet die WebSocket-Verbindung ...')
|
||||
: t('Es laeuft keine aktive Bot-Session');
|
||||
return (
|
||||
<div className={styles.directorHeader}>
|
||||
<div className={styles.directorHeaderLeft}>
|
||||
<h4 className={styles.directorTitle}>{t('Regieanweisungen')}</h4>
|
||||
<span
|
||||
className={`${styles.botStatusDot} ${botConnected ? styles.botStatusDotLive : styles.botStatusDotIdle}`}
|
||||
title={statusTitle}
|
||||
/>
|
||||
<span className={styles.directorMeta} title={statusTitle}>
|
||||
{statusLabel}
|
||||
</span>
|
||||
{activePersistentCount > 0 && (
|
||||
<span className={styles.directorBadge} title={t('Aktive persistente Anweisungen')}>
|
||||
{activePersistentCount}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className={styles.directorMeta}>
|
||||
{t('Privat - nur fuer den Bot sichtbar')}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
|
||||
<div className={styles.directorBody}>
|
||||
<textarea
|
||||
className={styles.directorTextarea}
|
||||
placeholder={t('Anweisung an den Bot (z. B. recherchiere ... und gib eine Empfehlung) ...')}
|
||||
value={directorText}
|
||||
maxLength={DIRECTOR_PROMPT_TEXT_LIMIT}
|
||||
onChange={(e) => setDirectorText(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if ((e.ctrlKey || e.metaKey) && e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
void _submitDirectorPrompt();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
{directorFiles.length > 0 && (
|
||||
<div className={styles.directorChips}>
|
||||
{directorFiles.map((f) => (
|
||||
<span key={f.id} className={styles.directorChip} title={f.name}>
|
||||
<span className={styles.directorChipName}>{f.name}</span>
|
||||
<button
|
||||
className={styles.directorChipRemove}
|
||||
onClick={() => _removeDirectorFile(f.id)}
|
||||
title={t('Entfernen')}
|
||||
>
|
||||
x
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className={styles.directorActions}>
|
||||
<div className={styles.directorRow}>
|
||||
<span className={styles.directorModeToggle}>
|
||||
<button
|
||||
className={`${styles.directorModeButton} ${directorMode === 'oneShot' ? styles.directorModeButtonActive : ''}`}
|
||||
onClick={() => setDirectorMode('oneShot')}
|
||||
type="button"
|
||||
>
|
||||
{t('Einmalig')}
|
||||
</button>
|
||||
<button
|
||||
className={`${styles.directorModeButton} ${directorMode === 'persistent' ? styles.directorModeButtonActive : ''}`}
|
||||
onClick={() => setDirectorMode('persistent')}
|
||||
type="button"
|
||||
>
|
||||
{t('Persistent')}
|
||||
</button>
|
||||
</span>
|
||||
<input
|
||||
ref={directorFileInputRef}
|
||||
type="file"
|
||||
multiple
|
||||
style={{ display: 'none' }}
|
||||
onChange={_onDirectorFileInput}
|
||||
/>
|
||||
<button
|
||||
className={styles.directorAttachBtn}
|
||||
onClick={() => directorFileInputRef.current?.click()}
|
||||
disabled={directorUploading || directorFiles.length >= DIRECTOR_PROMPT_FILE_LIMIT}
|
||||
title={t('Dateien anhaengen')}
|
||||
type="button"
|
||||
>
|
||||
{directorUploading ? t('Lade hoch ...') : t('Datei anhaengen')}
|
||||
</button>
|
||||
<span className={styles.directorMeta}>
|
||||
{directorText.length}/{DIRECTOR_PROMPT_TEXT_LIMIT} {t('Zeichen')} -
|
||||
{' '}{directorFiles.length}/{DIRECTOR_PROMPT_FILE_LIMIT} {t('Dateien')}
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
className={styles.directorSubmit}
|
||||
onClick={_submitDirectorPrompt}
|
||||
disabled={directorSubmitting || !directorText.trim() || !botConnected}
|
||||
type="button"
|
||||
title={botConnected ? t('Strg+Enter: Senden') : t('Bot ist noch nicht live verbunden')}
|
||||
>
|
||||
{directorSubmitting ? t('Senden ...') : t('An Bot senden')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{!botConnected && (
|
||||
<div className={styles.directorHint}>
|
||||
{session?.status === 'active'
|
||||
? t('Der Bot ist im Meeting, hat aber den WebSocket-Kanal zum Gateway nicht geoeffnet. Pruefe TEAMSBOT_BROWSER_BOT_URL/APP_API_URL und ob der Browser-Bot-Service das Gateway erreichen kann.')
|
||||
: t('Der Bot muss erst dem Meeting beitreten und sich verbinden, bevor Regieanweisungen ausgefuehrt werden koennen.')}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{directorError && (
|
||||
<div className={styles.errorBanner} style={{ margin: 0 }}>{directorError}</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{directorPrompts.length > 0 && (
|
||||
<div className={styles.directorHistory}>
|
||||
{directorPrompts.slice(0, 8).map((p) => (
|
||||
<div key={p.id} className={styles.directorHistoryItem}>
|
||||
<div className={styles.directorHistoryHead}>
|
||||
<span>
|
||||
{_formatTime(p.createdAt)} - {p.mode === 'persistent' ? t('Persistent') : t('Einmalig')}
|
||||
{p.fileIds && p.fileIds.length > 0 && ` - ${p.fileIds.length} ${t('Dateien')}`}
|
||||
</span>
|
||||
<span style={{ display: 'flex', gap: '6px', alignItems: 'center' }}>
|
||||
<span className={`${styles.directorStatus} ${styles[`directorStatus${p.status.charAt(0).toUpperCase() + p.status.slice(1)}`] || ''}`}>
|
||||
{p.status}
|
||||
</span>
|
||||
{p.mode === 'persistent' && p.status !== 'consumed' && (
|
||||
<button
|
||||
className={styles.directorRemoveBtn}
|
||||
onClick={() => _removeDirectorPrompt(p.id)}
|
||||
title={t('Persistente Anweisung entfernen')}
|
||||
type="button"
|
||||
>
|
||||
x
|
||||
</button>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
<div className={styles.directorHistoryText}>{p.text}</div>
|
||||
{p.responseText && (
|
||||
<div className={styles.directorHistoryText} style={{ opacity: 0.85 }}>
|
||||
<em>{t('Antwort')}:</em> {p.responseText}
|
||||
</div>
|
||||
)}
|
||||
{p.statusMessage && p.status === 'failed' && (
|
||||
<div className={styles.directorHistoryText} style={{ color: '#b91c1c' }}>
|
||||
{p.statusMessage}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Main Content: Transcript + Responses */}
|
||||
<div className={styles.sessionContent}>
|
||||
{/* Left: Transcript */}
|
||||
|
|
@ -501,6 +1004,9 @@ export const TeamsbotSessionView: React.FC = () => {
|
|||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
</div>{/* /sessionMain */}
|
||||
</div>{/* /sessionLayout */}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,13 +1,16 @@
|
|||
import React, { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import { useCurrentInstance } from '../../../hooks/useCurrentInstance';
|
||||
import * as teamsbotApi from '../../../api/teamsbotApi';
|
||||
import type { TeamsbotConfig, ConfigUpdateRequest, VoiceOption } from '../../../api/teamsbotApi';
|
||||
import type { TeamsbotConfig, ConfigUpdateRequest, VoiceOption, SystemBot } from '../../../api/teamsbotApi';
|
||||
import type { VoiceLanguage } from '../../../api/voiceCatalogApi';
|
||||
import { FaPlay, FaSpinner } from 'react-icons/fa';
|
||||
import { FaPlay, FaSpinner, FaTrash } from 'react-icons/fa';
|
||||
import styles from './Teamsbot.module.css';
|
||||
import { getUserDataCache } from '../../../utils/userCache';
|
||||
|
||||
import { useLanguage } from '../../../providers/language/LanguageContext';
|
||||
|
||||
type SettingsTabId = 'general' | 'systemBots';
|
||||
|
||||
/** Format voice name for display: "de-DE-Wavenet-A" -> "Wavenet A" + gender */
|
||||
function _formatVoiceName(voice: VoiceOption): string {
|
||||
const parts = voice.name.split('-');
|
||||
|
|
@ -148,12 +151,44 @@ export const TeamsbotSettingsView: React.FC = () => {
|
|||
}
|
||||
};
|
||||
|
||||
const cachedUser = getUserDataCache();
|
||||
const _isSysAdmin = cachedUser?.isSysAdmin === true;
|
||||
|
||||
const [activeTab, setActiveTab] = useState<SettingsTabId>('general');
|
||||
|
||||
if (loading) return <div className={styles.loading}>{t('Konfiguration laden')}</div>;
|
||||
|
||||
return (
|
||||
<div className={styles.settingsContainer}>
|
||||
{/* Tab navigation */}
|
||||
<div className={styles.settingsTabs} role="tablist">
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={activeTab === 'general'}
|
||||
className={`${styles.settingsTab} ${activeTab === 'general' ? styles.settingsTabActive : ''}`}
|
||||
onClick={() => setActiveTab('general')}
|
||||
>
|
||||
{t('Bot-Einstellungen')}
|
||||
</button>
|
||||
{_isSysAdmin && (
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={activeTab === 'systemBots'}
|
||||
className={`${styles.settingsTab} ${activeTab === 'systemBots' ? styles.settingsTabActive : ''}`}
|
||||
onClick={() => setActiveTab('systemBots')}
|
||||
>
|
||||
{t('System-Bots')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{activeTab === 'systemBots' && _isSysAdmin ? (
|
||||
<_SystemBotsPanel instanceId={instanceId} />
|
||||
) : (
|
||||
<div className={styles.settingsCard}>
|
||||
<h3 className={styles.cardTitle}>Bot-Einstellungen</h3>
|
||||
<h3 className={styles.cardTitle}>{t('Bot-Einstellungen')}</h3>
|
||||
|
||||
{error && <div className={styles.errorBanner}>{error}</div>}
|
||||
{successMsg && <div className={styles.successBanner}>{successMsg}</div>}
|
||||
|
|
@ -375,6 +410,266 @@ export const TeamsbotSettingsView: React.FC = () => {
|
|||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// System-Bots Tab (SysAdmin only)
|
||||
// ============================================================================
|
||||
|
||||
interface _SystemBotsPanelProps {
|
||||
instanceId: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* System-Bots panel: list, create and delete the mandate's authenticated
|
||||
* Teams bot accounts (email + encrypted password). Used as the SYSTEM_BOT
|
||||
* join mode in the Dashboard. Server stores the password encrypted and
|
||||
* never returns it; this panel only ever holds the password in memory
|
||||
* during the create form.
|
||||
*/
|
||||
const _SystemBotsPanel: React.FC<_SystemBotsPanelProps> = ({ instanceId }) => {
|
||||
const { t } = useLanguage();
|
||||
|
||||
const [bots, setBots] = useState<SystemBot[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [successMsg, setSuccessMsg] = useState<string | null>(null);
|
||||
|
||||
// Create form
|
||||
const [showForm, setShowForm] = useState(false);
|
||||
const [formName, setFormName] = useState('');
|
||||
const [formEmail, setFormEmail] = useState('');
|
||||
const [formPassword, setFormPassword] = useState('');
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const [creating, setCreating] = useState(false);
|
||||
|
||||
// Delete state (id of the bot currently being deleted, if any)
|
||||
const [deletingId, setDeletingId] = useState<string | null>(null);
|
||||
|
||||
const _load = useCallback(async () => {
|
||||
if (!instanceId) return;
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
const result = await teamsbotApi.listSystemBots(instanceId);
|
||||
setBots(result.bots || []);
|
||||
} catch (err: any) {
|
||||
setError(err?.message || t('Fehler beim Laden'));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [instanceId, t]);
|
||||
|
||||
useEffect(() => {
|
||||
_load();
|
||||
}, [_load]);
|
||||
|
||||
const _resetForm = () => {
|
||||
setFormName('');
|
||||
setFormEmail('');
|
||||
setFormPassword('');
|
||||
setShowPassword(false);
|
||||
setShowForm(false);
|
||||
};
|
||||
|
||||
const _flashSuccess = (msg: string) => {
|
||||
setSuccessMsg(msg);
|
||||
setTimeout(() => setSuccessMsg(null), 3000);
|
||||
};
|
||||
|
||||
const _handleCreate = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!formEmail.trim() || !formPassword) {
|
||||
setError(t('Email und Passwort sind erforderlich'));
|
||||
return;
|
||||
}
|
||||
setCreating(true);
|
||||
setError(null);
|
||||
try {
|
||||
await teamsbotApi.createSystemBot(instanceId, {
|
||||
email: formEmail.trim(),
|
||||
password: formPassword,
|
||||
name: formName.trim() || undefined,
|
||||
});
|
||||
_resetForm();
|
||||
await _load();
|
||||
_flashSuccess(t('System-Bot gespeichert'));
|
||||
} catch (err: any) {
|
||||
setError(err?.response?.data?.detail || err?.message || t('Fehler beim Speichern'));
|
||||
} finally {
|
||||
setCreating(false);
|
||||
}
|
||||
};
|
||||
|
||||
const _handleDelete = async (bot: SystemBot) => {
|
||||
const confirmed = window.confirm(
|
||||
t('System-Bot wirklich loeschen?') + `\n\n${bot.name} <${bot.email}>`,
|
||||
);
|
||||
if (!confirmed) return;
|
||||
setDeletingId(bot.id);
|
||||
setError(null);
|
||||
try {
|
||||
await teamsbotApi.deleteSystemBot(instanceId, bot.id);
|
||||
setBots(prev => prev.filter(b => b.id !== bot.id));
|
||||
_flashSuccess(t('System-Bot geloescht'));
|
||||
} catch (err: any) {
|
||||
setError(err?.response?.data?.detail || err?.message || t('Fehler beim Loeschen'));
|
||||
} finally {
|
||||
setDeletingId(null);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={styles.settingsCard}>
|
||||
<h3 className={styles.cardTitle}>{t('System-Bots')}</h3>
|
||||
<p className={styles.cardDescription}>
|
||||
{t('Authentifizierte Teams-Konten, mit denen der Bot Meetings beitreten kann. Passwoerter werden verschluesselt gespeichert und nie zurueckgegeben.')}
|
||||
</p>
|
||||
|
||||
{error && <div className={styles.errorBanner}>{error}</div>}
|
||||
{successMsg && <div className={styles.successBanner}>{successMsg}</div>}
|
||||
|
||||
{loading ? (
|
||||
<div className={styles.loading}>{t('Laden')}...</div>
|
||||
) : (
|
||||
<>
|
||||
{bots.length === 0 ? (
|
||||
<div className={styles.emptyState || ''} style={{ padding: '1rem 0', color: 'var(--text-secondary, #666)', fontSize: '0.9rem' }}>
|
||||
{t('Noch kein System-Bot konfiguriert. Anonyme Joins werden verwendet, bis ein Konto angelegt ist.')}
|
||||
</div>
|
||||
) : (
|
||||
<table className={styles.systemBotsTable || ''} style={{ width: '100%', borderCollapse: 'collapse', marginBottom: '1rem' }}>
|
||||
<thead>
|
||||
<tr style={{ textAlign: 'left', borderBottom: '1px solid var(--border-color, #e0e0e0)' }}>
|
||||
<th style={{ padding: '0.5rem 0.5rem 0.5rem 0', fontSize: '0.85rem', fontWeight: 600 }}>{t('Name')}</th>
|
||||
<th style={{ padding: '0.5rem', fontSize: '0.85rem', fontWeight: 600 }}>{t('Email')}</th>
|
||||
<th style={{ padding: '0.5rem', fontSize: '0.85rem', fontWeight: 600 }}>{t('Status')}</th>
|
||||
<th style={{ padding: '0.5rem 0 0.5rem 0.5rem', fontSize: '0.85rem', fontWeight: 600, textAlign: 'right' }}></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{bots.map(bot => (
|
||||
<tr key={bot.id} style={{ borderBottom: '1px solid var(--border-color, #f0f0f0)' }}>
|
||||
<td style={{ padding: '0.6rem 0.5rem 0.6rem 0', fontSize: '0.9rem' }}>{bot.name}</td>
|
||||
<td style={{ padding: '0.6rem 0.5rem', fontSize: '0.9rem', fontFamily: 'monospace' }}>{bot.email}</td>
|
||||
<td style={{ padding: '0.6rem 0.5rem', fontSize: '0.85rem' }}>
|
||||
{bot.isActive ? (
|
||||
<span style={{ color: 'var(--success-color, #2D8E5C)' }}>{t('Aktiv')}</span>
|
||||
) : (
|
||||
<span style={{ color: 'var(--text-secondary, #999)' }}>{t('Inaktiv')}</span>
|
||||
)}
|
||||
</td>
|
||||
<td style={{ padding: '0.6rem 0 0.6rem 0.5rem', textAlign: 'right' }}>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.deleteButton}
|
||||
onClick={() => _handleDelete(bot)}
|
||||
disabled={deletingId === bot.id}
|
||||
title={t('Loeschen')}
|
||||
style={{ display: 'inline-flex', alignItems: 'center', gap: '6px' }}
|
||||
>
|
||||
{deletingId === bot.id ? <FaSpinner className={styles.spinner} /> : <FaTrash />}
|
||||
{t('Loeschen')}
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
|
||||
{!showForm ? (
|
||||
<div className={styles.settingsActions}>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.saveButton}
|
||||
onClick={() => { setShowForm(true); setError(null); }}
|
||||
>
|
||||
{t('Neuen System-Bot anlegen')}
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<form onSubmit={_handleCreate} className={styles.settingsSection}>
|
||||
<h4 className={styles.sectionTitle}>{t('Neuer System-Bot')}</h4>
|
||||
|
||||
<div className={styles.formGroup}>
|
||||
<label className={styles.label}>{t('Anzeigename')}</label>
|
||||
<input
|
||||
type="text"
|
||||
className={styles.input}
|
||||
value={formName}
|
||||
onChange={(e) => setFormName(e.target.value)}
|
||||
placeholder={t('z.B. Nyla Larsson')}
|
||||
autoComplete="off"
|
||||
/>
|
||||
<span className={styles.hint}>
|
||||
{t('Optional. Falls leer, wird der Email-Localpart verwendet.')}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className={styles.formGroup}>
|
||||
<label className={styles.label}>{t('Email')} *</label>
|
||||
<input
|
||||
type="email"
|
||||
className={styles.input}
|
||||
value={formEmail}
|
||||
onChange={(e) => setFormEmail(e.target.value)}
|
||||
placeholder="bot@example.onmicrosoft.com"
|
||||
autoComplete="off"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={styles.formGroup}>
|
||||
<label className={styles.label}>{t('Passwort')} *</label>
|
||||
<div style={{ display: 'flex', gap: '8px', alignItems: 'center' }}>
|
||||
<input
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
className={styles.input}
|
||||
style={{ flex: 1 }}
|
||||
value={formPassword}
|
||||
onChange={(e) => setFormPassword(e.target.value)}
|
||||
autoComplete="new-password"
|
||||
required
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.viewButton}
|
||||
onClick={() => setShowPassword(s => !s)}
|
||||
>
|
||||
{showPassword ? t('Verbergen') : t('Anzeigen')}
|
||||
</button>
|
||||
</div>
|
||||
<span className={styles.hint}>
|
||||
{t('Wird serverseitig verschluesselt gespeichert und nie zurueckgegeben.')}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className={styles.settingsActions} style={{ display: 'flex', gap: '8px' }}>
|
||||
<button
|
||||
type="submit"
|
||||
className={styles.saveButton}
|
||||
disabled={creating}
|
||||
>
|
||||
{creating ? <FaSpinner className={styles.spinner} /> : null}
|
||||
{creating ? t('Speichern') : t('System-Bot speichern')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.viewButton}
|
||||
onClick={_resetForm}
|
||||
disabled={creating}
|
||||
>
|
||||
{t('Abbrechen')}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -69,6 +69,7 @@ export const TrusteeAccountingSettingsView: React.FC = () => {
|
|||
const [clearingCache, setClearingCache] = useState(false);
|
||||
const [exporting, setExporting] = useState(false);
|
||||
const [importPeriod, setImportPeriod] = useState<PeriodValue | null>(null);
|
||||
const [wipingData, setWipingData] = useState(false);
|
||||
const { confirm, ConfirmDialog } = useConfirm();
|
||||
|
||||
useEffect(() => {
|
||||
|
|
@ -438,7 +439,9 @@ export const TrusteeAccountingSettingsView: React.FC = () => {
|
|||
const lastSyncAt = importStatus?.lastSyncAt as number | null | undefined;
|
||||
const winFrom = importStatus?.lastSyncDateFrom as string | undefined;
|
||||
const winTo = importStatus?.lastSyncDateTo as string | undefined;
|
||||
const counts = (importStatus?.lastSyncCounts || {}) as Record<string, number>;
|
||||
const counts = (importStatus?.lastSyncCounts || {}) as Record<string, any>;
|
||||
const oldestBooking = counts.oldestBookingDate as string | null | undefined;
|
||||
const newestBooking = counts.newestBookingDate as string | null | undefined;
|
||||
const timeWindow = winFrom && winTo
|
||||
? t('{from} bis {to}', { from: winFrom, to: winTo })
|
||||
: winFrom
|
||||
|
|
@ -446,6 +449,13 @@ export const TrusteeAccountingSettingsView: React.FC = () => {
|
|||
: winTo
|
||||
? t('bis {to}', { to: winTo })
|
||||
: null;
|
||||
const dataWindow = oldestBooking && newestBooking
|
||||
? t('{from} bis {to}', { from: oldestBooking, to: newestBooking })
|
||||
: oldestBooking
|
||||
? t('ab {from}', { from: oldestBooking })
|
||||
: newestBooking
|
||||
? t('bis {to}', { to: newestBooking })
|
||||
: null;
|
||||
return (
|
||||
<div style={{
|
||||
fontSize: '0.8rem',
|
||||
|
|
@ -461,9 +471,18 @@ export const TrusteeAccountingSettingsView: React.FC = () => {
|
|||
<div>
|
||||
<strong>{t('Letzter Import:')}</strong> {new Date(lastSyncAt * 1000).toLocaleString()}
|
||||
{timeWindow && (
|
||||
<> {' '}· <strong>{t('Zeitfenster:')}</strong> {timeWindow}</>
|
||||
<> {' '}· <strong>{t('Angefragtes Zeitfenster:')}</strong> {timeWindow}</>
|
||||
)}
|
||||
</div>
|
||||
{dataWindow && (
|
||||
<div style={{ marginTop: '0.2rem' }}>
|
||||
<strong>{t('Tatsächlich erhaltene Buchungen:')}</strong>{' '}
|
||||
{dataWindow}
|
||||
<span style={{ marginLeft: '0.5rem', fontStyle: 'italic' }}>
|
||||
({t('älteste/neuste Buchung im Import — zur Vollständigkeitsprüfung')})
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<div style={{ marginTop: '0.2rem' }}>
|
||||
{t('{konten} Konten, {buchungen} Buchungen ({zeilen} Zeilen), {kontakte} Kontakte, {salden} Salden', {
|
||||
konten: String(counts.accounts ?? 0),
|
||||
|
|
@ -547,12 +566,13 @@ export const TrusteeAccountingSettingsView: React.FC = () => {
|
|||
<button
|
||||
className={styles.secondaryButton}
|
||||
disabled={clearingCache}
|
||||
title={t('Leert nur den Antwort-Cache des KI-Agenten (~5 Min). Synchronisierte Daten bleiben unverändert.')}
|
||||
onClick={async () => {
|
||||
if (!instanceId) return;
|
||||
setClearingCache(true);
|
||||
try {
|
||||
const result = await request({ url: `/api/trustee/${instanceId}/accounting/clear-cache`, method: 'post' });
|
||||
showSuccess(t('Cache geleert'), t('{n} gecachte Abfragen entfernt. Die nächste KI-Abfrage liest frische Daten.', { n: String(result?.cleared ?? 0) }));
|
||||
showSuccess(t('KI-Antwort-Cache geleert'), t('{n} gecachte KI-Antworten entfernt. Die nächste KI-Abfrage berechnet frische Antworten aus den synchronisierten Tabellen. Diese Aktion löscht KEINE importierten Buchungen.', { n: String(result?.cleared ?? 0) }));
|
||||
} catch (err: any) {
|
||||
showError(t('Fehler'), err.response?.data?.detail || err.message || t('Cache konnte nicht geleert werden.'));
|
||||
} finally {
|
||||
|
|
@ -560,7 +580,41 @@ export const TrusteeAccountingSettingsView: React.FC = () => {
|
|||
}
|
||||
}}
|
||||
>
|
||||
{clearingCache ? t('Leere…') : t('KI-Cache leeren')}
|
||||
{clearingCache ? t('Leere…') : t('KI-Antwort-Cache leeren')}
|
||||
</button>
|
||||
<button
|
||||
className={styles.secondaryButton}
|
||||
disabled={wipingData}
|
||||
title={t('Löscht alle importierten Buchungen, Konten, Kontakte und Salden für diese Instanz. Die Verbindungseinstellungen bleiben erhalten.')}
|
||||
style={{ color: 'var(--error-color, #dc2626)' }}
|
||||
onClick={async () => {
|
||||
if (!instanceId) return;
|
||||
const ok = await confirm(
|
||||
t('Wirklich alle importierten Buchhaltungsdaten dieser Instanz aus der lokalen Datenbank löschen? Die Verbindungseinstellungen bleiben erhalten. Sie können danach jederzeit erneut importieren.'),
|
||||
{
|
||||
title: t('Importierte Daten löschen'),
|
||||
confirmLabel: t('Löschen'),
|
||||
variant: 'danger',
|
||||
},
|
||||
);
|
||||
if (!ok) return;
|
||||
setWipingData(true);
|
||||
try {
|
||||
const result = await request({ url: `/api/trustee/${instanceId}/accounting/wipe-imported-data`, method: 'post' });
|
||||
showSuccess(
|
||||
t('Daten gelöscht'),
|
||||
t('{n} Datensätze entfernt. Sie können nun einen frischen Import starten.', { n: String(result?.totalRemoved ?? 0) }),
|
||||
);
|
||||
_loadImportStatus();
|
||||
void loadData();
|
||||
} catch (err: any) {
|
||||
showError(t('Fehler'), err.response?.data?.detail || err.message || t('Daten konnten nicht gelöscht werden.'));
|
||||
} finally {
|
||||
setWipingData(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{wipingData ? t('Lösche…') : t('Importierte Daten löschen')}
|
||||
</button>
|
||||
<button
|
||||
className={styles.secondaryButton}
|
||||
|
|
|
|||
|
|
@ -3,6 +3,13 @@
|
|||
*
|
||||
* Dokument-Verwaltung für eine Trustee-Instanz.
|
||||
* Verwendet FormGeneratorTable für konsistentes UI.
|
||||
*
|
||||
* NOTE: Mounted only as a tab inside `TrusteeDataTablesView` (Tab `documents`
|
||||
* unter `/mandates/{m}/trustee/{i}/data-tables?tab=documents`). Es gibt keine
|
||||
* eigenständige Top-Level-Route mehr (`/trustee/{i}/documents` wurde entfernt
|
||||
* -- siehe `wiki/c-work/4-done/2026-04-trustee-cleanup-positions-documents.md`).
|
||||
* Direkt-Import durch `TrusteeDataTablesView`; kein Re-Export über
|
||||
* `views/trustee/index.ts`.
|
||||
*/
|
||||
|
||||
import React, { useState, useMemo, useEffect } from 'react';
|
||||
|
|
@ -16,6 +23,7 @@ import api from '../../../api';
|
|||
import styles from '../../admin/Admin.module.css';
|
||||
|
||||
import { useLanguage } from '../../../providers/language/LanguageContext';
|
||||
import { resolveColumnTypes } from '../../../utils/columnTypeResolver';
|
||||
|
||||
export const TrusteeDocumentsView: React.FC = () => {
|
||||
const { t } = useLanguage();
|
||||
|
|
@ -63,13 +71,13 @@ export const TrusteeDocumentsView: React.FC = () => {
|
|||
const allCols = (attributes || []).map(attr => ({
|
||||
key: attr.name,
|
||||
label: attr.label || attr.name,
|
||||
type: attr.type as any,
|
||||
sortable: attr.sortable !== false,
|
||||
filterable: attr.filterable !== false,
|
||||
searchable: attr.searchable !== false,
|
||||
width: attr.width || 150,
|
||||
minWidth: attr.minWidth || 100,
|
||||
maxWidth: attr.maxWidth || 400,
|
||||
displayField: attr.displayField,
|
||||
}));
|
||||
const byKey = new Map(allCols.map(c => [c.key, c]));
|
||||
const ordered: typeof allCols = [];
|
||||
|
|
@ -80,7 +88,7 @@ export const TrusteeDocumentsView: React.FC = () => {
|
|||
for (const col of allCols) {
|
||||
if (byKey.has(col.key)) ordered.push(col);
|
||||
}
|
||||
return ordered;
|
||||
return resolveColumnTypes(ordered, attributes || []);
|
||||
}, [attributes]);
|
||||
|
||||
// Check permissions
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ import { FaSync } from 'react-icons/fa';
|
|||
import styles from '../../admin/Admin.module.css';
|
||||
|
||||
import { useLanguage } from '../../../providers/language/LanguageContext';
|
||||
import { resolveColumnTypes } from '../../../utils/columnTypeResolver';
|
||||
|
||||
export const TrusteePositionDocumentsView: React.FC = () => {
|
||||
const { t } = useLanguage();
|
||||
|
|
@ -50,39 +51,27 @@ export const TrusteePositionDocumentsView: React.FC = () => {
|
|||
}
|
||||
}, [instanceId]);
|
||||
|
||||
// Generate columns from attributes (like TrusteePositionsView)
|
||||
// Map frontend_options to fkSource for FK resolution
|
||||
const columns = useMemo(() => {
|
||||
if (!attributes || attributes.length === 0) return [];
|
||||
|
||||
// Exclude system fields from table columns
|
||||
const excludedFields = ['id', 'mandateId', 'featureInstanceId', 'sysCreatedBy', 'sysCreatedAt', 'sysModifiedAt', 'sysModifiedBy'];
|
||||
|
||||
return attributes
|
||||
const raw = attributes
|
||||
.filter((attr: any) => !excludedFields.includes(attr.name))
|
||||
.map((attr: any) => {
|
||||
// Replace {instanceId} placeholder in options URL
|
||||
let fkSource = attr.options;
|
||||
if (typeof fkSource === 'string' && instanceId) {
|
||||
fkSource = fkSource.replace('{instanceId}', instanceId);
|
||||
}
|
||||
|
||||
return {
|
||||
key: attr.name,
|
||||
label: attr.label || attr.name,
|
||||
type: attr.type as any,
|
||||
sortable: attr.sortable !== false,
|
||||
filterable: attr.filterable !== false,
|
||||
searchable: attr.searchable !== false,
|
||||
width: attr.width || 200,
|
||||
minWidth: attr.minWidth || 100,
|
||||
maxWidth: attr.maxWidth || 400,
|
||||
// Use frontend_options as fkSource for FK resolution
|
||||
fkSource: typeof fkSource === 'string' ? fkSource : undefined,
|
||||
fkDisplayField: 'label',
|
||||
};
|
||||
});
|
||||
}, [attributes, instanceId]);
|
||||
.map((attr: any) => ({
|
||||
key: attr.name,
|
||||
label: attr.label || attr.name,
|
||||
sortable: attr.sortable !== false,
|
||||
filterable: attr.filterable !== false,
|
||||
searchable: attr.searchable !== false,
|
||||
width: attr.width || 200,
|
||||
minWidth: attr.minWidth || 100,
|
||||
maxWidth: attr.maxWidth || 400,
|
||||
displayField: attr.displayField,
|
||||
}));
|
||||
return resolveColumnTypes(raw, attributes);
|
||||
}, [attributes]);
|
||||
|
||||
// Check permissions (general level)
|
||||
// Row-level permissions are handled automatically by FormGeneratorTable
|
||||
|
|
|
|||
|
|
@ -3,6 +3,13 @@
|
|||
*
|
||||
* Positions-Verwaltung für eine Trustee-Instanz.
|
||||
* Verwendet FormGeneratorTable für konsistentes UI.
|
||||
*
|
||||
* NOTE: Mounted only as a tab inside `TrusteeDataTablesView` (Tab `positions`
|
||||
* unter `/mandates/{m}/trustee/{i}/data-tables?tab=positions`). Es gibt keine
|
||||
* eigenständige Top-Level-Route mehr (`/trustee/{i}/positions` wurde entfernt
|
||||
* -- siehe `wiki/c-work/4-done/2026-04-trustee-cleanup-positions-documents.md`).
|
||||
* Direkt-Import durch `TrusteeDataTablesView`; kein Re-Export über
|
||||
* `views/trustee/index.ts`.
|
||||
*/
|
||||
|
||||
import React, { useState, useMemo, useEffect, useCallback } from 'react';
|
||||
|
|
@ -14,11 +21,12 @@ import { FormGeneratorForm } from '../../../components/FormGenerator/FormGenerat
|
|||
import { FaSync, FaDownload } from 'react-icons/fa';
|
||||
import { useToast } from '../../../contexts/ToastContext';
|
||||
import api from '../../../api';
|
||||
import { fetchSyncStatus, syncPositionsToAccounting, type AccountingSyncStatus } from '../../../api/trusteeApi';
|
||||
import { syncPositionsToAccounting } from '../../../api/trusteeApi';
|
||||
import { formatAmount, formatPercent } from '../../../utils/formatAmount';
|
||||
import styles from '../../admin/Admin.module.css';
|
||||
|
||||
import { useLanguage } from '../../../providers/language/LanguageContext';
|
||||
import { resolveColumnTypes } from '../../../utils/columnTypeResolver';
|
||||
|
||||
export const TrusteePositionsView: React.FC = () => {
|
||||
const { t } = useLanguage();
|
||||
|
|
@ -27,7 +35,6 @@ export const TrusteePositionsView: React.FC = () => {
|
|||
const { request } = useApiRequest();
|
||||
const { showError, showSuccess } = useToast();
|
||||
const [downloadingDocIds, setDownloadingDocIds] = useState<Set<string>>(new Set());
|
||||
const [syncStatusItems, setSyncStatusItems] = useState<AccountingSyncStatus[]>([]);
|
||||
const [syncingPositionIds, setSyncingPositionIds] = useState<Set<string>>(new Set());
|
||||
|
||||
// Entity hook
|
||||
|
|
@ -63,25 +70,6 @@ export const TrusteePositionsView: React.FC = () => {
|
|||
}
|
||||
}, [instanceId]);
|
||||
|
||||
// Load sync status for Sync-Status column
|
||||
useEffect(() => {
|
||||
if (!instanceId) return;
|
||||
let cancelled = false;
|
||||
fetchSyncStatus(request, instanceId)
|
||||
.then((data) => {
|
||||
if (!cancelled && data?.items) setSyncStatusItems(data.items);
|
||||
})
|
||||
.catch(() => {});
|
||||
return () => { cancelled = true; };
|
||||
}, [instanceId, request]);
|
||||
|
||||
const _reloadSyncStatus = useCallback(() => {
|
||||
if (!instanceId) return;
|
||||
fetchSyncStatus(request, instanceId)
|
||||
.then((data) => data?.items && setSyncStatusItems(data.items))
|
||||
.catch(() => {});
|
||||
}, [instanceId, request]);
|
||||
|
||||
const handleBatchSyncToAccounting = useCallback(
|
||||
async (rows: TrusteePosition[]) => {
|
||||
if (!instanceId || rows.length === 0) return;
|
||||
|
|
@ -97,8 +85,8 @@ export const TrusteePositionsView: React.FC = () => {
|
|||
const firstError = res.results?.find((r: any) => !r.success);
|
||||
showError('Sync fehlgeschlagen', firstError?.errorMessage || `${res.errors} Fehler.`);
|
||||
}
|
||||
// Refetch positions — the route now serves syncStatus inline.
|
||||
refetch();
|
||||
_reloadSyncStatus();
|
||||
} catch (err: any) {
|
||||
showError('Sync fehlgeschlagen', err.response?.data?.detail || err.message || 'Unbekannter Fehler.');
|
||||
} finally {
|
||||
|
|
@ -109,7 +97,7 @@ export const TrusteePositionsView: React.FC = () => {
|
|||
});
|
||||
}
|
||||
},
|
||||
[instanceId, request, refetch, _reloadSyncStatus, showSuccess, showError]
|
||||
[instanceId, request, refetch, showSuccess, showError]
|
||||
);
|
||||
|
||||
const handleSingleSyncToAccounting = useCallback(
|
||||
|
|
@ -212,55 +200,11 @@ export const TrusteePositionsView: React.FC = () => {
|
|||
},
|
||||
}), [handleDownloadDocument, downloadingDocIds]);
|
||||
|
||||
// Map positionId -> display sync status: prefer synced over error (successful retry hides old error)
|
||||
const syncByPosition = useMemo(() => {
|
||||
const m = new Map<string, { syncStatus: string; errorMessage?: string }>();
|
||||
for (const s of syncStatusItems) {
|
||||
const cur = m.get(s.positionId);
|
||||
const prefer =
|
||||
!cur ||
|
||||
s.syncStatus === 'synced' ||
|
||||
(cur.syncStatus !== 'synced' && s.syncStatus === 'error');
|
||||
if (prefer) m.set(s.positionId, { syncStatus: s.syncStatus, errorMessage: s.errorMessage });
|
||||
}
|
||||
return m;
|
||||
}, [syncStatusItems]);
|
||||
|
||||
const syncStatusColumn: ColumnConfig = useMemo(
|
||||
() => ({
|
||||
key: '_syncStatus',
|
||||
label: t('Synchronisierungsstatus'),
|
||||
sortable: false,
|
||||
filterable: false,
|
||||
searchable: false,
|
||||
width: 160,
|
||||
minWidth: 100,
|
||||
maxWidth: 280,
|
||||
formatter: (_value: unknown, row: TrusteePosition) => {
|
||||
const info = syncByPosition.get(row.id);
|
||||
if (!info)
|
||||
return <span style={{ color: 'var(--text-secondary)' }}>—</span>;
|
||||
if (info.syncStatus === 'error')
|
||||
return (
|
||||
<span
|
||||
title={info.errorMessage || ''}
|
||||
style={{ color: 'var(--error-color, #dc2626)' }}
|
||||
>
|
||||
Fehler{info.errorMessage ? ': ' + (info.errorMessage.length > 40 ? info.errorMessage.slice(0, 37) + '…' : info.errorMessage) : ''}
|
||||
</span>
|
||||
);
|
||||
if (info.syncStatus === 'synced')
|
||||
return <span style={{ color: 'var(--success-color, #16a34a)' }}>Synchronisiert</span>;
|
||||
return <span>{info.syncStatus}</span>;
|
||||
},
|
||||
}),
|
||||
[syncByPosition]
|
||||
);
|
||||
|
||||
const positionColumnOrder = [
|
||||
'sysCreatedAt',
|
||||
'_documentRefs',
|
||||
'_syncStatus',
|
||||
'syncStatus',
|
||||
'syncErrorMessage',
|
||||
'valuta',
|
||||
'tags',
|
||||
'company',
|
||||
|
|
@ -278,13 +222,13 @@ export const TrusteePositionsView: React.FC = () => {
|
|||
const col: ColumnConfig = {
|
||||
key: attr.name,
|
||||
label: attr.label || attr.name,
|
||||
type: attr.type as any,
|
||||
sortable: attr.sortable !== false,
|
||||
filterable: attr.filterable !== false,
|
||||
searchable: attr.searchable !== false,
|
||||
width: attr.width || 150,
|
||||
minWidth: attr.minWidth || 100,
|
||||
maxWidth: attr.maxWidth || 400,
|
||||
displayField: attr.displayField,
|
||||
};
|
||||
if (amountFields.has(attr.name)) {
|
||||
col.formatter = (v: unknown) => formatAmount(v);
|
||||
|
|
@ -296,7 +240,7 @@ export const TrusteePositionsView: React.FC = () => {
|
|||
return col;
|
||||
});
|
||||
|
||||
const allColumns = [...attrColumns, belegeColumn, syncStatusColumn];
|
||||
const allColumns = [...attrColumns, belegeColumn];
|
||||
const byKey = new Map(allColumns.map(c => [c.key, c]));
|
||||
|
||||
const ordered: typeof allColumns = [];
|
||||
|
|
@ -312,8 +256,8 @@ export const TrusteePositionsView: React.FC = () => {
|
|||
const col = byKey.get(key);
|
||||
if (col) ordered.push(col);
|
||||
}
|
||||
return ordered;
|
||||
}, [attributes, belegeColumn, syncStatusColumn]);
|
||||
return resolveColumnTypes(ordered, attributes || []);
|
||||
}, [attributes, belegeColumn]);
|
||||
|
||||
// Check permissions
|
||||
const canCreate = permissions?.create !== 'n';
|
||||
|
|
|
|||
|
|
@ -25,6 +25,7 @@ import { FormGeneratorForm } from '../../../../components/FormGenerator/FormGene
|
|||
import { useLanguage } from '../../../../providers/language/LanguageContext';
|
||||
import { useInstanceId } from '../../../../hooks/useCurrentInstance';
|
||||
import adminStyles from '../../../admin/Admin.module.css';
|
||||
import { resolveColumnTypes } from '../../../../utils/columnTypeResolver';
|
||||
|
||||
export interface TrusteeDataTabProps {
|
||||
/** Result of the entity hook factory call (see `useTrustee.ts`). */
|
||||
|
|
@ -117,23 +118,22 @@ export const TrusteeDataTab: React.FC<TrusteeDataTabProps> = ({
|
|||
|
||||
const columns = useMemo(() => {
|
||||
const hidden = new Set([..._DEFAULT_HIDDEN_COLUMNS, ...(hiddenColumns || [])]);
|
||||
return (attributes || [])
|
||||
const raw = (attributes || [])
|
||||
.filter((attr: any) => !hidden.has(attr.name))
|
||||
.map((attr: any) => ({
|
||||
key: attr.name,
|
||||
label: attr.label || attr.name,
|
||||
type: (attr.type as any) || 'text',
|
||||
sortable: attr.sortable !== false,
|
||||
filterable: attr.filterable !== false,
|
||||
searchable: attr.searchable !== false,
|
||||
width: attr.width || 150,
|
||||
minWidth: attr.minWidth || 100,
|
||||
maxWidth: attr.maxWidth || 400,
|
||||
fkSource: attr.fkSource,
|
||||
fkDisplayField: attr.fkDisplayField,
|
||||
displayField: attr.displayField,
|
||||
frontendFormat: attr.frontendFormat,
|
||||
frontendFormatLabels: attr.frontendFormatLabels,
|
||||
}));
|
||||
return resolveColumnTypes(raw, attributes || []);
|
||||
}, [attributes, hiddenColumns]);
|
||||
|
||||
const formAttributes = useMemo(() => {
|
||||
|
|
|
|||
|
|
@ -1,10 +1,15 @@
|
|||
/**
|
||||
* Trustee Views Export
|
||||
*
|
||||
* NOTE: `TrusteePositionsView` und `TrusteeDocumentsView` werden hier
|
||||
* ABSICHTLICH NICHT mehr re-exportiert. Beide Komponenten sind nur noch
|
||||
* Tab-Bodies innerhalb von `TrusteeDataTablesView` und werden dort per
|
||||
* Direkt-Import (`./TrusteePositionsView`, `./TrusteeDocumentsView`) geladen.
|
||||
* Externe Importe ueber dieses Index-File sind nicht mehr unterstuetzt --
|
||||
* siehe `wiki/c-work/4-done/2026-04-trustee-cleanup-positions-documents.md`.
|
||||
*/
|
||||
|
||||
export { TrusteeDashboardView } from './TrusteeDashboardView';
|
||||
export { TrusteeDocumentsView } from './TrusteeDocumentsView';
|
||||
export { TrusteePositionsView } from './TrusteePositionsView';
|
||||
export { TrusteeInstanceRolesView } from './TrusteeInstanceRolesView';
|
||||
export { TrusteeExpenseImportView } from './TrusteeExpenseImportView';
|
||||
export { TrusteeScanUploadView } from './TrusteeScanUploadView';
|
||||
|
|
|
|||
|
|
@ -17,11 +17,12 @@ interface TrusteeGraphNode {
|
|||
position: { x: number; y: number };
|
||||
}
|
||||
|
||||
/** Matches automation2 ``buildConnectionMap`` (``sourceOutput`` / ``targetInput``). */
|
||||
interface TrusteeGraphConnection {
|
||||
source: string;
|
||||
sourcePort: number;
|
||||
sourceOutput: number;
|
||||
target: string;
|
||||
targetPort: number;
|
||||
targetInput: number;
|
||||
}
|
||||
|
||||
export interface TrusteeGraph {
|
||||
|
|
@ -68,7 +69,7 @@ export function _buildScanUploadGraph(
|
|||
_method: 'trustee',
|
||||
_action: 'processDocuments',
|
||||
parameters: {
|
||||
documentList: [],
|
||||
documentList: { type: 'ref', nodeId: 'extract', path: ['documents'] },
|
||||
featureInstanceId: trusteeInstanceId,
|
||||
},
|
||||
position: { x: 500, y: 0 },
|
||||
|
|
@ -80,7 +81,7 @@ export function _buildScanUploadGraph(
|
|||
_method: 'trustee',
|
||||
_action: 'syncToAccounting',
|
||||
parameters: {
|
||||
documentList: [],
|
||||
documentList: { type: 'ref', nodeId: 'process', path: ['documents'] },
|
||||
featureInstanceId: trusteeInstanceId,
|
||||
},
|
||||
position: { x: 750, y: 0 },
|
||||
|
|
@ -88,9 +89,9 @@ export function _buildScanUploadGraph(
|
|||
];
|
||||
|
||||
const connections: TrusteeGraphConnection[] = [
|
||||
{ source: 'trigger-manual', sourcePort: 0, target: 'extract', targetPort: 0 },
|
||||
{ source: 'extract', sourcePort: 0, target: 'process', targetPort: 0 },
|
||||
{ source: 'process', sourcePort: 0, target: 'sync', targetPort: 0 },
|
||||
{ source: 'trigger-manual', sourceOutput: 0, target: 'extract', targetInput: 0 },
|
||||
{ source: 'extract', sourceOutput: 0, target: 'process', targetInput: 0 },
|
||||
{ source: 'process', sourceOutput: 0, target: 'sync', targetInput: 0 },
|
||||
];
|
||||
|
||||
return { nodes, connections };
|
||||
|
|
@ -137,7 +138,7 @@ export function _buildExpenseImportGraph(
|
|||
_method: 'trustee',
|
||||
_action: 'processDocuments',
|
||||
parameters: {
|
||||
documentList: [],
|
||||
documentList: { type: 'ref', nodeId: 'extract', path: ['documents'] },
|
||||
featureInstanceId: trusteeInstanceId,
|
||||
},
|
||||
position: { x: 500, y: 0 },
|
||||
|
|
@ -149,7 +150,7 @@ export function _buildExpenseImportGraph(
|
|||
_method: 'trustee',
|
||||
_action: 'syncToAccounting',
|
||||
parameters: {
|
||||
documentList: [],
|
||||
documentList: { type: 'ref', nodeId: 'process', path: ['documents'] },
|
||||
featureInstanceId: trusteeInstanceId,
|
||||
},
|
||||
position: { x: 750, y: 0 },
|
||||
|
|
@ -157,9 +158,9 @@ export function _buildExpenseImportGraph(
|
|||
];
|
||||
|
||||
const connections: TrusteeGraphConnection[] = [
|
||||
{ source: 'trigger-manual', sourcePort: 0, target: 'extract', targetPort: 0 },
|
||||
{ source: 'extract', sourcePort: 0, target: 'process', targetPort: 0 },
|
||||
{ source: 'process', sourcePort: 0, target: 'sync', targetPort: 0 },
|
||||
{ source: 'trigger-manual', sourceOutput: 0, target: 'extract', targetInput: 0 },
|
||||
{ source: 'extract', sourceOutput: 0, target: 'process', targetInput: 0 },
|
||||
{ source: 'process', sourceOutput: 0, target: 'sync', targetInput: 0 },
|
||||
];
|
||||
|
||||
return { nodes, connections };
|
||||
|
|
@ -210,7 +211,7 @@ export function _buildScheduledExpenseImportGraph(
|
|||
_method: 'trustee',
|
||||
_action: 'processDocuments',
|
||||
parameters: {
|
||||
documentList: [],
|
||||
documentList: { type: 'ref', nodeId: 'extract', path: ['documents'] },
|
||||
featureInstanceId: trusteeInstanceId,
|
||||
},
|
||||
position: { x: 500, y: 0 },
|
||||
|
|
@ -222,7 +223,7 @@ export function _buildScheduledExpenseImportGraph(
|
|||
_method: 'trustee',
|
||||
_action: 'syncToAccounting',
|
||||
parameters: {
|
||||
documentList: [],
|
||||
documentList: { type: 'ref', nodeId: 'process', path: ['documents'] },
|
||||
featureInstanceId: trusteeInstanceId,
|
||||
},
|
||||
position: { x: 750, y: 0 },
|
||||
|
|
@ -230,9 +231,9 @@ export function _buildScheduledExpenseImportGraph(
|
|||
];
|
||||
|
||||
const connections: TrusteeGraphConnection[] = [
|
||||
{ source: 'trigger-schedule', sourcePort: 0, target: 'extract', targetPort: 0 },
|
||||
{ source: 'extract', sourcePort: 0, target: 'process', targetPort: 0 },
|
||||
{ source: 'process', sourcePort: 0, target: 'sync', targetPort: 0 },
|
||||
{ source: 'trigger-schedule', sourceOutput: 0, target: 'extract', targetInput: 0 },
|
||||
{ source: 'extract', sourceOutput: 0, target: 'process', targetInput: 0 },
|
||||
{ source: 'process', sourceOutput: 0, target: 'sync', targetInput: 0 },
|
||||
];
|
||||
|
||||
return { nodes, connections };
|
||||
|
|
|
|||
|
|
@ -5,6 +5,11 @@
|
|||
* survives route changes. Visibility is toggled via CSS `display`
|
||||
* instead of mount / unmount, preserving messages, SSE connections,
|
||||
* files, and all other workspace state.
|
||||
*
|
||||
* Persistence is scoped per `(mandateId, instanceId)` — switching to a
|
||||
* different mandate or instance via the navigator unmounts the previous
|
||||
* page and mounts a fresh one (otherwise stale state from tenant A
|
||||
* leaks into tenant B).
|
||||
*/
|
||||
|
||||
import React, { useRef } from 'react';
|
||||
|
|
@ -19,15 +24,19 @@ interface WorkspaceKeepAliveProps {
|
|||
|
||||
export const WorkspaceKeepAlive: React.FC<WorkspaceKeepAliveProps> = ({ isVisible }) => {
|
||||
const location = useLocation();
|
||||
const cachedMandateIdRef = useRef<string>('');
|
||||
const cachedInstanceIdRef = useRef<string>('');
|
||||
|
||||
const match = location.pathname.match(_WORKSPACE_ROUTE_RE);
|
||||
if (match?.[2]) {
|
||||
if (match?.[1] && match?.[2]) {
|
||||
cachedMandateIdRef.current = match[1];
|
||||
cachedInstanceIdRef.current = match[2];
|
||||
}
|
||||
|
||||
const mandateId = cachedMandateIdRef.current;
|
||||
const instanceId = cachedInstanceIdRef.current;
|
||||
if (!instanceId) return null;
|
||||
const scopeKey = `${mandateId}:${instanceId}`;
|
||||
|
||||
return (
|
||||
<div
|
||||
|
|
@ -42,7 +51,7 @@ export const WorkspaceKeepAlive: React.FC<WorkspaceKeepAliveProps> = ({ isVisibl
|
|||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
<WorkspacePage persistentInstanceId={instanceId} />
|
||||
<WorkspacePage key={scopeKey} persistentInstanceId={instanceId} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
45
src/test/setup.ts
Normal file
45
src/test/setup.ts
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
// Copyright (c) 2025 Patrick Motsch
|
||||
// All rights reserved.
|
||||
//
|
||||
// Vitest global setup: jest-dom matchers + jsdom polyfills required by some
|
||||
// of our components (ResizeObserver, matchMedia, scrollIntoView).
|
||||
|
||||
import '@testing-library/jest-dom/vitest';
|
||||
import { afterEach } from 'vitest';
|
||||
import { cleanup } from '@testing-library/react';
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
class _ResizeObserverPolyfill {
|
||||
observe(): void {}
|
||||
unobserve(): void {}
|
||||
disconnect(): void {}
|
||||
}
|
||||
|
||||
if (!('ResizeObserver' in globalThis)) {
|
||||
(globalThis as unknown as { ResizeObserver: typeof _ResizeObserverPolyfill }).ResizeObserver =
|
||||
_ResizeObserverPolyfill;
|
||||
}
|
||||
|
||||
if (!('matchMedia' in window)) {
|
||||
Object.defineProperty(window, 'matchMedia', {
|
||||
writable: true,
|
||||
value: (query: string) => ({
|
||||
matches: false,
|
||||
media: query,
|
||||
onchange: null,
|
||||
addListener: () => {},
|
||||
removeListener: () => {},
|
||||
addEventListener: () => {},
|
||||
removeEventListener: () => {},
|
||||
dispatchEvent: () => false,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
if (!('scrollIntoView' in HTMLElement.prototype)) {
|
||||
(HTMLElement.prototype as unknown as { scrollIntoView: () => void }).scrollIntoView =
|
||||
function (): void {};
|
||||
}
|
||||
23
src/test/smoke.test.ts
Normal file
23
src/test/smoke.test.ts
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
// Copyright (c) 2025 Patrick Motsch
|
||||
// All rights reserved.
|
||||
//
|
||||
// Smoke test that validates the Vitest + jsdom setup is wired correctly.
|
||||
// If this fails the rest of the suite is meaningless.
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
describe('vitest smoke', () => {
|
||||
it('runs in jsdom and has window/document', () => {
|
||||
expect(typeof window).toBe('object');
|
||||
expect(typeof document).toBe('object');
|
||||
});
|
||||
|
||||
it('has jest-dom matchers via globals setup', () => {
|
||||
const div = document.createElement('div');
|
||||
div.textContent = 'hello';
|
||||
document.body.appendChild(div);
|
||||
expect(div).toBeInTheDocument();
|
||||
expect(div).toHaveTextContent('hello');
|
||||
document.body.removeChild(div);
|
||||
});
|
||||
});
|
||||
|
|
@ -24,7 +24,9 @@ export type AttributeType =
|
|||
| 'string'
|
||||
| 'enum'
|
||||
| 'slug'
|
||||
| 'readonly';
|
||||
| 'readonly'
|
||||
| 'object'
|
||||
| 'json';
|
||||
|
||||
export type InputComponentType =
|
||||
| 'text'
|
||||
|
|
@ -82,6 +84,7 @@ export function attributeTypeToInputType(attributeType: AttributeType): InputCom
|
|||
return 'multiselect';
|
||||
|
||||
case 'integer':
|
||||
case 'int':
|
||||
case 'number':
|
||||
case 'float':
|
||||
return 'number';
|
||||
|
|
@ -114,6 +117,10 @@ export function attributeTypeToInputType(attributeType: AttributeType): InputCom
|
|||
case 'readonly':
|
||||
return 'text'; // Default to text for readonly, but should be rendered as readonly
|
||||
|
||||
case 'object':
|
||||
case 'json':
|
||||
return 'textarea';
|
||||
|
||||
default:
|
||||
// Default fallback to text input
|
||||
return 'text';
|
||||
|
|
@ -124,7 +131,7 @@ export function attributeTypeToInputType(attributeType: AttributeType): InputCom
|
|||
* Determines if an attribute type should render as a textarea
|
||||
*/
|
||||
export function isTextareaType(attributeType: AttributeType): boolean {
|
||||
return attributeType === 'textarea';
|
||||
return attributeType === 'textarea' || attributeType === 'object' || attributeType === 'json';
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -166,7 +173,12 @@ export function isFileType(attributeType: AttributeType): boolean {
|
|||
* Determines if an attribute type should render as a number input
|
||||
*/
|
||||
export function isNumberType(attributeType: AttributeType): boolean {
|
||||
return attributeType === 'integer' || attributeType === 'number' || attributeType === 'float';
|
||||
return (
|
||||
attributeType === 'integer'
|
||||
|| attributeType === 'int'
|
||||
|| attributeType === 'number'
|
||||
|| attributeType === 'float'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -176,6 +188,25 @@ export function isDateTimeType(attributeType: AttributeType): boolean {
|
|||
return attributeType === 'timestamp' || attributeType === 'date' || attributeType === 'time';
|
||||
}
|
||||
|
||||
/**
|
||||
* Subset of AttributeType suitable for user-facing form fields (workflow start forms,
|
||||
* input forms, field builders). Components render labels via t(FORM_FIELD_TYPE_LABELS[ft]).
|
||||
*/
|
||||
export const FORM_FIELD_TYPES: AttributeType[] = [
|
||||
'text', 'textarea', 'number', 'email', 'date', 'boolean', 'select', 'checkbox',
|
||||
];
|
||||
|
||||
export const FORM_FIELD_TYPE_LABELS: Record<string, string> = {
|
||||
text: 'Text',
|
||||
textarea: 'Mehrzeilig',
|
||||
number: 'Zahl',
|
||||
email: 'E-Mail',
|
||||
date: 'Datum',
|
||||
boolean: 'Ja/Nein',
|
||||
select: 'Auswahl',
|
||||
checkbox: 'Kontrollkästchen',
|
||||
};
|
||||
|
||||
/**
|
||||
* Gets the default value for an attribute type
|
||||
*/
|
||||
|
|
|
|||
77
src/utils/columnTypeResolver.ts
Normal file
77
src/utils/columnTypeResolver.ts
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
/**
|
||||
* Resolves column types from backend attribute definitions.
|
||||
*
|
||||
* Pages define column configs with UI metadata (label, width, formatter, etc.)
|
||||
* but omit the `type` field. This utility merges the backend-provided attribute
|
||||
* type into each column, ensuring a single source of truth for column types.
|
||||
*/
|
||||
|
||||
import type { ColumnConfig } from '../components/FormGenerator/FormGeneratorTable';
|
||||
import type { AttributeType } from './attributeTypeMapper';
|
||||
|
||||
export interface AttributeLike {
|
||||
name: string;
|
||||
type?: string;
|
||||
label?: string;
|
||||
displayField?: string;
|
||||
frontendFormat?: string;
|
||||
frontendFormatLabels?: string[];
|
||||
/** Backend-provided options. Accepts the canonical `{value,label}` array
|
||||
* (preferred), a legacy plain `string[]` (treated as value==label), or a
|
||||
* string reference (e.g. `"user.role"`) — only the array forms are merged
|
||||
* into `ColumnConfig.options`; references are ignored here. */
|
||||
options?: Array<{ value: string | number; label: string }> | string[] | string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge backend attribute metadata into page-defined column configs.
|
||||
*
|
||||
* For each column, the following fields are resolved from the matching backend
|
||||
* attribute (by `key === attr.name`) when the column does not already define
|
||||
* them: `type`, `label`, `displayField`, `frontendFormat`, `frontendFormatLabels`.
|
||||
*
|
||||
* Pages must NOT hardcode display data (labels, value translations) — they
|
||||
* declare which columns to show, the backend declares the metadata.
|
||||
*/
|
||||
export function resolveColumnTypes(
|
||||
columns: ColumnConfig[],
|
||||
attributes: AttributeLike[],
|
||||
): ColumnConfig[] {
|
||||
if (!attributes || attributes.length === 0) return columns;
|
||||
|
||||
const attrMap = new Map<string, AttributeLike>();
|
||||
for (const attr of attributes) {
|
||||
attrMap.set(attr.name, attr);
|
||||
}
|
||||
|
||||
return columns.map((col) => {
|
||||
const attr = attrMap.get(col.key);
|
||||
if (!attr) return col;
|
||||
|
||||
const merged: ColumnConfig = { ...col };
|
||||
if (attr.type) {
|
||||
merged.type = attr.type as AttributeType;
|
||||
}
|
||||
if (attr.label && !col.label) {
|
||||
merged.label = attr.label;
|
||||
}
|
||||
if (attr.displayField && !col.displayField) {
|
||||
merged.displayField = attr.displayField;
|
||||
}
|
||||
if (attr.frontendFormat && !col.frontendFormat) {
|
||||
merged.frontendFormat = attr.frontendFormat;
|
||||
}
|
||||
if (attr.frontendFormatLabels && !col.frontendFormatLabels) {
|
||||
merged.frontendFormatLabels = attr.frontendFormatLabels;
|
||||
}
|
||||
if (Array.isArray(attr.options) && !col.options) {
|
||||
// Normalise legacy `string[]` to the canonical `{value,label}` shape.
|
||||
const opts = attr.options as Array<string | { value: string | number; label: string }>;
|
||||
const normalised = opts.map((o) =>
|
||||
typeof o === 'string' ? { value: o, label: o } : o,
|
||||
);
|
||||
merged.options = normalised;
|
||||
}
|
||||
return merged;
|
||||
});
|
||||
}
|
||||
489
tsc-out.txt
489
tsc-out.txt
|
|
@ -1,489 +0,0 @@
|
|||
src/components/FlowEditor/nodes/configs/CommentNodeConfig.tsx(8,1): error TS6133: 'useLanguage' is declared but its value is never read.
|
||||
src/components/FlowEditor/nodes/configs/CommentNodeConfig.tsx(17,22): error TS2304: Cannot find name 't'.
|
||||
src/components/FlowEditor/nodes/configs/ConfirmationNodeConfig.tsx(8,1): error TS6133: 'useLanguage' is declared but its value is never read.
|
||||
src/components/FlowEditor/nodes/configs/ConfirmationNodeConfig.tsx(17,22): error TS2304: Cannot find name 't'.
|
||||
src/components/FlowEditor/nodes/configs/ConfirmationNodeConfig.tsx(21,15): error TS2304: Cannot find name 't'.
|
||||
src/components/FlowEditor/nodes/configs/EmailNodeConfig.tsx(9,1): error TS6133: 'useLanguage' is declared but its value is never read.
|
||||
src/components/FlowEditor/nodes/configs/EmailNodeConfig.tsx(61,39): error TS2304: Cannot find name 't'.
|
||||
src/components/FlowEditor/nodes/configs/EmailNodeConfig.tsx(61,70): error TS2304: Cannot find name 't'.
|
||||
src/components/FlowEditor/nodes/configs/EmailNodeConfig.tsx(78,72): error TS2304: Cannot find name 't'.
|
||||
src/components/FlowEditor/nodes/configs/EmailNodeConfig.tsx(78,114): error TS2304: Cannot find name 't'.
|
||||
src/components/FlowEditor/nodes/configs/EmailNodeConfig.tsx(96,48): error TS2304: Cannot find name 't'.
|
||||
src/components/FlowEditor/nodes/configs/EmailNodeConfig.tsx(97,51): error TS2304: Cannot find name 't'.
|
||||
src/components/FlowEditor/nodes/configs/EmailNodeConfig.tsx(98,48): error TS2304: Cannot find name 't'.
|
||||
src/components/FlowEditor/nodes/configs/EmailNodeConfig.tsx(115,21): error TS2304: Cannot find name 't'.
|
||||
src/components/FlowEditor/nodes/configs/EmailNodeConfig.tsx(119,28): error TS2304: Cannot find name 't'.
|
||||
src/components/FlowEditor/nodes/configs/EmailNodeConfig.tsx(123,21): error TS2304: Cannot find name 't'.
|
||||
src/components/FlowEditor/nodes/configs/EmailNodeConfig.tsx(127,28): error TS2304: Cannot find name 't'.
|
||||
src/components/FlowEditor/nodes/configs/EmailNodeConfig.tsx(131,21): error TS2304: Cannot find name 't'.
|
||||
src/components/FlowEditor/nodes/configs/EmailNodeConfig.tsx(135,28): error TS2304: Cannot find name 't'.
|
||||
src/components/FlowEditor/nodes/configs/EmailNodeConfig.tsx(139,21): error TS2304: Cannot find name 't'.
|
||||
src/components/FlowEditor/nodes/configs/EmailNodeConfig.tsx(143,28): error TS2304: Cannot find name 't'.
|
||||
src/components/FlowEditor/nodes/configs/EmailNodeConfig.tsx(147,21): error TS2304: Cannot find name 't'.
|
||||
src/components/FlowEditor/nodes/configs/EmailNodeConfig.tsx(151,28): error TS2304: Cannot find name 't'.
|
||||
src/components/FlowEditor/nodes/configs/EmailNodeConfig.tsx(161,51): error TS2304: Cannot find name 't'.
|
||||
src/components/FlowEditor/nodes/configs/EmailNodeConfig.tsx(176,21): error TS2304: Cannot find name 't'.
|
||||
src/components/FlowEditor/nodes/configs/EmailNodeConfig.tsx(180,28): error TS2304: Cannot find name 't'.
|
||||
src/components/FlowEditor/nodes/configs/EmailNodeConfig.tsx(184,21): error TS2304: Cannot find name 't'.
|
||||
src/components/FlowEditor/nodes/configs/EmailNodeConfig.tsx(188,28): error TS2304: Cannot find name 't'.
|
||||
src/components/FlowEditor/nodes/configs/EmailNodeConfig.tsx(198,45): error TS2304: Cannot find name 't'.
|
||||
src/components/FlowEditor/nodes/configs/EmailNodeConfig.tsx(217,28): error TS2304: Cannot find name 't'.
|
||||
src/components/FlowEditor/nodes/configs/EmailNodeConfig.tsx(225,28): error TS2304: Cannot find name 't'.
|
||||
src/components/FlowEditor/nodes/configs/EmailNodeConfig.tsx(230,21): error TS2304: Cannot find name 't'.
|
||||
src/components/FlowEditor/nodes/configs/EmailNodeConfig.tsx(234,28): error TS2304: Cannot find name 't'.
|
||||
src/components/FlowEditor/nodes/configs/FileCreateNodeConfig.tsx(12,1): error TS6133: 'useLanguage' is declared but its value is never read.
|
||||
src/components/FlowEditor/nodes/configs/FileCreateNodeConfig.tsx(46,17): error TS2304: Cannot find name 't'.
|
||||
src/components/FlowEditor/nodes/configs/FileCreateNodeConfig.tsx(52,28): error TS2304: Cannot find name 't'.
|
||||
src/components/FlowEditor/nodes/configs/FileCreateNodeConfig.tsx(58,22): error TS2304: Cannot find name 't'.
|
||||
src/components/FlowEditor/nodes/configs/FileCreateNodeConfig.tsx(59,27): error TS2304: Cannot find name 't'.
|
||||
src/components/FlowEditor/nodes/configs/FileCreateNodeConfig.tsx(96,17): error TS2304: Cannot find name 't'.
|
||||
src/components/FlowEditor/nodes/configs/FileCreateNodeConfig.tsx(109,17): error TS2304: Cannot find name 't'.
|
||||
src/components/FlowEditor/nodes/configs/SharePointNodeConfig.tsx(13,1): error TS6133: 'useLanguage' is declared but its value is never read.
|
||||
src/components/FlowEditor/nodes/configs/SharePointNodeConfig.tsx(157,50): error TS2304: Cannot find name 't'.
|
||||
src/components/FlowEditor/nodes/configs/SharePointNodeConfig.tsx(157,86): error TS2304: Cannot find name 't'.
|
||||
src/components/FlowEditor/nodes/configs/SharePointNodeConfig.tsx(168,19): error TS2304: Cannot find name 't'.
|
||||
src/components/FlowEditor/nodes/configs/SharePointNodeConfig.tsx(179,19): error TS2304: Cannot find name 't'.
|
||||
src/components/FlowEditor/nodes/configs/SharePointNodeConfig.tsx(194,19): error TS2304: Cannot find name 't'.
|
||||
src/components/FlowEditor/nodes/configs/SharePointNodeConfig.tsx(195,19): error TS2304: Cannot find name 't'.
|
||||
src/components/FlowEditor/nodes/configs/SharePointNodeConfig.tsx(213,19): error TS2304: Cannot find name 't'.
|
||||
src/components/FlowEditor/nodes/configs/SharePointNodeConfig.tsx(217,26): error TS2304: Cannot find name 't'.
|
||||
src/components/FlowEditor/nodes/configs/SharePointNodeConfig.tsx(225,21): error TS2304: Cannot find name 't'.
|
||||
src/components/FlowEditor/nodes/configs/SharePointNodeConfig.tsx(233,21): error TS2304: Cannot find name 't'.
|
||||
src/components/FlowEditor/nodes/configs/TrusteeNodeConfig.tsx(10,1): error TS6133: 'useLanguage' is declared but its value is never read.
|
||||
src/components/FlowEditor/nodes/configs/TrusteeNodeConfig.tsx(36,17): error TS2304: Cannot find name 't'.
|
||||
src/components/FlowEditor/nodes/configs/TrusteeNodeConfig.tsx(40,24): error TS2304: Cannot find name 't'.
|
||||
src/components/FlowEditor/nodes/configs/TrusteeNodeConfig.tsx(47,21): error TS2304: Cannot find name 't'.
|
||||
src/components/FlowEditor/nodes/configs/TrusteeNodeConfig.tsx(53,43): error TS2304: Cannot find name 't'.
|
||||
src/components/FlowEditor/nodes/configs/TrusteeNodeConfig.tsx(53,74): error TS2304: Cannot find name 't'.
|
||||
src/components/FlowEditor/nodes/configs/TrusteeNodeConfig.tsx(62,21): error TS2304: Cannot find name 't'.
|
||||
src/components/FlowEditor/nodes/configs/TrusteeNodeConfig.tsx(70,21): error TS2304: Cannot find name 't'.
|
||||
src/components/FlowEditor/nodes/configs/TrusteeNodeConfig.tsx(74,28): error TS2304: Cannot find name 't'.
|
||||
src/components/FlowEditor/nodes/configs/TrusteeNodeConfig.tsx(83,19): error TS2304: Cannot find name 't'.
|
||||
src/components/FlowEditor/nodes/configs/TrusteeNodeConfig.tsx(87,26): error TS2304: Cannot find name 't'.
|
||||
src/components/FlowEditor/nodes/configs/UploadNodeConfig.tsx(11,1): error TS6133: 'useLanguage' is declared but its value is never read.
|
||||
src/components/FlowEditor/nodes/configs/UploadNodeConfig.tsx(42,17): error TS2304: Cannot find name 't'.
|
||||
src/components/FlowEditor/nodes/configs/UploadNodeConfig.tsx(60,17): error TS2304: Cannot find name 't'.
|
||||
src/components/FlowEditor/nodes/form/FormNodeConfig.tsx(11,1): error TS6133: 'useLanguage' is declared but its value is never read.
|
||||
src/components/FlowEditor/nodes/form/FormNodeConfig.tsx(78,24): error TS2304: Cannot find name 't'.
|
||||
src/components/FlowEditor/nodes/form/FormNodeConfig.tsx(135,48): error TS2304: Cannot find name 't'.
|
||||
src/components/FlowEditor/nodes/form/FormNodeConfig.tsx(136,49): error TS2304: Cannot find name 't'.
|
||||
src/components/FlowEditor/nodes/form/FormNodeConfig.tsx(153,24): error TS2304: Cannot find name 't'.
|
||||
src/components/FlowEditor/nodes/form/FormNodeConfig.tsx(200,32): error TS2304: Cannot find name 't'.
|
||||
src/components/FlowEditor/nodes/frontendTypeRenderers/index.tsx(28,1): error TS6133: 'useLanguage' is declared but its value is never read.
|
||||
src/components/FlowEditor/nodes/frontendTypeRenderers/index.tsx(157,13): error TS2345: Argument of type 'string' is not assignable to parameter of type 'ApiRequestOptions<any>'.
|
||||
src/components/FlowEditor/nodes/frontendTypeRenderers/index.tsx(172,27): error TS2304: Cannot find name 't'.
|
||||
src/components/FlowEditor/nodes/frontendTypeRenderers/index.tsx(216,34): error TS2304: Cannot find name 't'.
|
||||
src/components/FlowEditor/nodes/frontendTypeRenderers/index.tsx(218,33): error TS2304: Cannot find name 't'.
|
||||
src/components/FlowEditor/nodes/frontendTypeRenderers/index.tsx(219,33): error TS2304: Cannot find name 't'.
|
||||
src/components/FlowEditor/nodes/frontendTypeRenderers/index.tsx(225,141): error TS2304: Cannot find name 't'.
|
||||
src/components/FlowEditor/nodes/frontendTypeRenderers/index.tsx(260,142): error TS2304: Cannot find name 't'.
|
||||
src/components/FlowEditor/nodes/frontendTypeRenderers/index.tsx(284,140): error TS2304: Cannot find name 't'.
|
||||
src/components/FlowEditor/nodes/frontendTypeRenderers/index.tsx(296,20): error TS2304: Cannot find name 't'.
|
||||
src/components/FlowEditor/nodes/frontendTypeRenderers/index.tsx(299,68): error TS2304: Cannot find name 't'.
|
||||
src/components/FlowEditor/nodes/frontendTypeRenderers/index.tsx(312,32): error TS2304: Cannot find name 't'.
|
||||
src/components/FlowEditor/nodes/frontendTypeRenderers/index.tsx(313,31): error TS2304: Cannot find name 't'.
|
||||
src/components/FlowEditor/nodes/frontendTypeRenderers/index.tsx(314,31): error TS2304: Cannot find name 't'.
|
||||
src/components/FlowEditor/nodes/frontendTypeRenderers/index.tsx(316,34): error TS2304: Cannot find name 't'.
|
||||
src/components/FlowEditor/nodes/frontendTypeRenderers/index.tsx(317,38): error TS2304: Cannot find name 't'.
|
||||
src/components/FlowEditor/nodes/frontendTypeRenderers/index.tsx(318,36): error TS2304: Cannot find name 't'.
|
||||
src/components/FlowEditor/nodes/frontendTypeRenderers/index.tsx(319,37): error TS2304: Cannot find name 't'.
|
||||
src/components/FlowEditor/nodes/frontendTypeRenderers/index.tsx(341,43): error TS2304: Cannot find name 't'.
|
||||
src/components/FlowEditor/nodes/frontendTypeRenderers/index.tsx(343,43): error TS2304: Cannot find name 't'.
|
||||
src/components/FlowEditor/nodes/frontendTypeRenderers/index.tsx(347,144): error TS2304: Cannot find name 't'.
|
||||
src/components/FlowEditor/nodes/frontendTypeRenderers/index.tsx(362,32): error TS2304: Cannot find name 't'.
|
||||
src/components/FlowEditor/nodes/frontendTypeRenderers/index.tsx(364,39): error TS2304: Cannot find name 't'.
|
||||
src/components/FlowEditor/nodes/frontendTypeRenderers/index.tsx(365,36): error TS2304: Cannot find name 't'.
|
||||
src/components/FlowEditor/nodes/frontendTypeRenderers/index.tsx(366,39): error TS2304: Cannot find name 't'.
|
||||
src/components/FlowEditor/nodes/frontendTypeRenderers/index.tsx(367,31): error TS2304: Cannot find name 't'.
|
||||
src/components/FlowEditor/nodes/frontendTypeRenderers/index.tsx(368,31): error TS2304: Cannot find name 't'.
|
||||
src/components/FlowEditor/nodes/ifElse/IfElseNodeConfig.tsx(15,1): error TS6133: 'useLanguage' is declared but its value is never read.
|
||||
src/components/FlowEditor/nodes/ifElse/IfElseNodeConfig.tsx(102,78): error TS2304: Cannot find name 't'.
|
||||
src/components/FlowEditor/nodes/ifElse/IfElseNodeConfig.tsx(122,33): error TS2304: Cannot find name 't'.
|
||||
src/components/FlowEditor/nodes/ifElse/IfElseNodeConfig.tsx(144,25): error TS2304: Cannot find name 't'.
|
||||
src/components/FlowEditor/nodes/ifElse/IfElseNodeConfig.tsx(145,25): error TS2304: Cannot find name 't'.
|
||||
src/components/FlowEditor/nodes/shared/DataPicker.tsx(14,1): error TS6133: 'useLanguage' is declared but its value is never read.
|
||||
src/components/FlowEditor/nodes/shared/DataPicker.tsx(142,51): error TS2304: Cannot find name 't'.
|
||||
src/components/FlowEditor/nodes/shared/DataPicker.tsx(143,98): error TS2304: Cannot find name 't'.
|
||||
src/components/FlowEditor/nodes/shared/DataPicker.tsx(184,61): error TS2304: Cannot find name 't'.
|
||||
src/components/FlowEditor/nodes/shared/DataPicker.tsx(192,43): error TS2345: Argument of type 'CanvasConnection[]' is not assignable to parameter of type '{ source: string; target: string; sourceOutput?: number | undefined; }[]'.
|
||||
Type 'CanvasConnection' is missing the following properties from type '{ source: string; target: string; sourceOutput?: number | undefined; }': source, target
|
||||
src/components/FlowEditor/nodes/shared/DynamicValueField.tsx(17,1): error TS6133: 'useLanguage' is declared but its value is never read.
|
||||
src/components/FlowEditor/nodes/shared/DynamicValueField.tsx(59,54): error TS2304: Cannot find name 't'.
|
||||
src/components/FlowEditor/nodes/shared/DynamicValueField.tsx(104,26): error TS2345: Argument of type 'DataRef | SystemVarRef' is not assignable to parameter of type 'DataRef | null'.
|
||||
Type 'SystemVarRef' is missing the following properties from type 'DataRef': nodeId, path
|
||||
src/components/FlowEditor/nodes/shared/HybridStaticRefField.tsx(16,1): error TS6133: 'useLanguage' is declared but its value is never read.
|
||||
src/components/FlowEditor/nodes/shared/HybridStaticRefField.tsx(87,26): error TS2304: Cannot find name 't'.
|
||||
src/components/FlowEditor/nodes/shared/LoopItemsSelect.tsx(12,1): error TS6133: 'useLanguage' is declared but its value is never read.
|
||||
src/components/FlowEditor/nodes/shared/LoopItemsSelect.tsx(186,15): error TS2304: Cannot find name 't'.
|
||||
src/components/FlowEditor/nodes/switch/SwitchNodeConfig.tsx(15,1): error TS6133: 'useLanguage' is declared but its value is never read.
|
||||
src/components/FlowEditor/nodes/switch/SwitchNodeConfig.tsx(116,29): error TS2304: Cannot find name 't'.
|
||||
src/components/FlowEditor/nodes/switch/SwitchNodeConfig.tsx(156,29): error TS2304: Cannot find name 't'.
|
||||
src/components/FlowEditor/nodes/switch/SwitchNodeConfig.tsx(157,33): error TS2304: Cannot find name 't'.
|
||||
src/components/FlowEditor/nodes/switch/SwitchNodeConfig.tsx(158,34): error TS2304: Cannot find name 't'.
|
||||
src/components/FlowEditor/nodes/switch/SwitchNodeConfig.tsx(191,24): error TS2304: Cannot find name 't'.
|
||||
src/components/FlowEditor/nodes/switch/SwitchNodeConfig.tsx(197,19): error TS2304: Cannot find name 't'.
|
||||
src/components/FlowEditor/nodes/switch/SwitchNodeConfig.tsx(202,26): error TS2304: Cannot find name 't'.
|
||||
src/components/FlowEditor/nodes/switch/SwitchNodeConfig.tsx(208,17): error TS2304: Cannot find name 't'.
|
||||
src/components/NotificationBell/NotificationBell.tsx(13,1): error TS6133: 'useLanguage' is declared but its value is never read.
|
||||
src/components/NotificationBell/NotificationBell.tsx(161,18): error TS2304: Cannot find name 't'.
|
||||
src/components/NotificationBell/NotificationBell.tsx(175,48): error TS2304: Cannot find name 't'.
|
||||
src/components/NotificationBell/NotificationBell.tsx(185,21): error TS2304: Cannot find name 't'.
|
||||
src/components/NotificationBell/NotificationBell.tsx(255,33): error TS2304: Cannot find name 't'.
|
||||
src/components/OnboardingWizard.tsx(4,1): error TS6133: 'useLanguage' is declared but its value is never read.
|
||||
src/components/OnboardingWizard.tsx(26,18): error TS2304: Cannot find name 't'.
|
||||
src/components/OnboardingWizard.tsx(48,64): error TS2304: Cannot find name 't'.
|
||||
src/components/OnboardingWizard.tsx(62,24): error TS2304: Cannot find name 't'.
|
||||
src/components/OnboardingWizard.tsx(77,24): error TS2304: Cannot find name 't'.
|
||||
src/components/OnboardingWizard.tsx(92,26): error TS2304: Cannot find name 't'.
|
||||
src/components/OnboardingWizard.tsx(116,24): error TS2304: Cannot find name 't'.
|
||||
src/components/OnboardingWizard.tsx(116,65): error TS2304: Cannot find name 't'.
|
||||
src/components/ProviderSelector/ProviderSelector.tsx(21,1): error TS6133: 'useLanguage' is declared but its value is never read.
|
||||
src/components/ProviderSelector/ProviderSelector.tsx(145,27): error TS2304: Cannot find name 't'.
|
||||
src/components/ProviderSelector/ProviderSelector.tsx(283,16): error TS2304: Cannot find name 't'.
|
||||
src/components/ProviderSelector/ProviderSelector.tsx(304,46): error TS2304: Cannot find name 't'.
|
||||
src/components/ProviderSelector/ProviderSelector.tsx(348,51): error TS2304: Cannot find name 't'.
|
||||
src/components/RbacExportImport/RbacExportImport.tsx(90,53): error TS2304: Cannot find name 't'.
|
||||
src/components/UiComponents/AddressAutocomplete/AddressAutocomplete.tsx(7,1): error TS6133: 'useLanguage' is declared but its value is never read.
|
||||
src/components/UiComponents/AddressAutocomplete/AddressAutocomplete.tsx(278,55): error TS2304: Cannot find name 't'.
|
||||
src/components/UiComponents/AddressAutocomplete/AddressAutocomplete.tsx(288,57): error TS2304: Cannot find name 't'.
|
||||
src/components/UiComponents/AutoScroll/AutoScroll.tsx(4,1): error TS6133: 'useLanguage' is declared but its value is never read.
|
||||
src/components/UiComponents/AutoScroll/AutoScroll.tsx(152,23): error TS2304: Cannot find name 't'.
|
||||
src/components/UiComponents/BauvorschriftenSection/BauvorschriftenSection.tsx(5,1): error TS6133: 'useLanguage' is declared but its value is never read.
|
||||
src/components/UiComponents/BauvorschriftenSection/BauvorschriftenSection.tsx(44,49): error TS2304: Cannot find name 't'.
|
||||
src/components/UiComponents/BauvorschriftenSection/BauvorschriftenSection.tsx(56,49): error TS2304: Cannot find name 't'.
|
||||
src/components/UiComponents/BauvorschriftenSection/BauvorschriftenSection.tsx(68,49): error TS2304: Cannot find name 't'.
|
||||
src/components/UiComponents/BauvorschriftenSection/BauvorschriftenSection.tsx(74,49): error TS2304: Cannot find name 't'.
|
||||
src/components/UiComponents/BauvorschriftenSection/BauvorschriftenSection.tsx(80,49): error TS2304: Cannot find name 't'.
|
||||
src/components/UiComponents/ConnectedFilesList/ConnectedFilesList.tsx(15,1): error TS6133: 'useLanguage' is declared but its value is never read.
|
||||
src/components/UiComponents/ConnectedFilesList/ConnectedFilesList.tsx(186,39): error TS2304: Cannot find name 't'.
|
||||
src/components/UiComponents/ConnectedFilesList/ConnectedFilesList.tsx(233,52): error TS2304: Cannot find name 't'.
|
||||
src/components/UiComponents/ConnectedFilesList/ConnectedFilesList.tsx(233,100): error TS2304: Cannot find name 't'.
|
||||
src/components/UiComponents/ConnectedFilesList/ConnectedFilesList.tsx(240,42): error TS2304: Cannot find name 't'.
|
||||
src/components/UiComponents/ConnectedFilesList/ConnectedFilesList.tsx(240,82): error TS2304: Cannot find name 't'.
|
||||
src/components/UiComponents/ConnectedFilesList/ConnectedFilesList.tsx(250,60): error TS2304: Cannot find name 't'.
|
||||
src/components/UiComponents/ConnectedFilesList/ConnectedFilesList.tsx(250,95): error TS2304: Cannot find name 't'.
|
||||
src/components/UiComponents/MapView/MapViewLeaflet.tsx(28,1): error TS6133: 'useLanguage' is declared but its value is never read.
|
||||
src/components/UiComponents/MapView/MapViewLeaflet.tsx(196,47): error TS2304: Cannot find name 't'.
|
||||
src/components/UiComponents/MapView/MapViewLeaflet.tsx(196,107): error TS2304: Cannot find name 't'.
|
||||
src/components/UiComponents/MapView/MapViewLeaflet.tsx(209,45): error TS2304: Cannot find name 't'.
|
||||
src/components/UiComponents/MapView/MapViewLeaflet.tsx(209,105): error TS2304: Cannot find name 't'.
|
||||
src/components/UiComponents/Messages/ChatMessages/ChatMessage.tsx(12,1): error TS6133: 'useLanguage' is declared but its value is never read.
|
||||
src/components/UiComponents/Messages/ChatMessages/ChatMessage.tsx(83,20): error TS2304: Cannot find name 't'.
|
||||
src/components/UiComponents/Messages/ChatMessages/ChatMessage.tsx(203,28): error TS2304: Cannot find name 't'.
|
||||
src/components/UiComponents/Messages/ChatMessages/ChatMessage.tsx(211,28): error TS2304: Cannot find name 't'.
|
||||
src/components/UiComponents/Messages/ChatMessages/ChatMessage.tsx(221,26): error TS2304: Cannot find name 't'.
|
||||
src/components/UiComponents/ParcelInfoPanel/ParcelInfoPanel.tsx(10,1): error TS6133: 'useLanguage' is declared but its value is never read.
|
||||
src/components/UiComponents/ParcelInfoPanel/ParcelInfoPanel.tsx(229,59): error TS2304: Cannot find name 't'.
|
||||
src/components/UiComponents/ParcelInfoPanel/ParcelInfoPanel.tsx(268,34): error TS2304: Cannot find name 't'.
|
||||
src/components/UiComponents/ParcelInfoPanel/ParcelInfoPanel.tsx(319,59): error TS2304: Cannot find name 't'.
|
||||
src/components/UiComponents/ParcelInfoPanel/ParcelInfoPanel.tsx(332,59): error TS2304: Cannot find name 't'.
|
||||
src/components/UiComponents/ParcelInfoPanel/ParcelInfoPanel.tsx(346,60): error TS2304: Cannot find name 't'.
|
||||
src/components/UiComponents/ParcelInfoPanel/ParcelInfoPanel.tsx(364,44): error TS2304: Cannot find name 't'.
|
||||
src/components/UiComponents/ParcelInfoPanel/ParcelInfoPanel.tsx(382,38): error TS2304: Cannot find name 't'.
|
||||
src/components/UiComponents/ParcelInfoPanel/ParcelInfoPanel.tsx(406,73): error TS2304: Cannot find name 't'.
|
||||
src/components/UiComponents/ParcelInfoPanel/ParcelInfoPanel.tsx(423,73): error TS2304: Cannot find name 't'.
|
||||
src/components/UiComponents/ParcelInfoPanel/ParcelInfoPanel.tsx(442,73): error TS2304: Cannot find name 't'.
|
||||
src/components/UiComponents/ParcelInfoPanel/ParcelInfoPanel.tsx(493,74): error TS2304: Cannot find name 't'.
|
||||
src/components/UiComponents/ParcelInfoPanel/ParcelInfoPanel.tsx(534,34): error TS2304: Cannot find name 't'.
|
||||
src/components/UiComponents/ParcelInfoPanel/ParcelInfoPanel.tsx(585,59): error TS2304: Cannot find name 't'.
|
||||
src/components/UiComponents/ParcelInfoPanel/ParcelInfoPanel.tsx(598,59): error TS2304: Cannot find name 't'.
|
||||
src/components/UiComponents/ParcelInfoPanel/ParcelInfoPanel.tsx(612,60): error TS2304: Cannot find name 't'.
|
||||
src/components/UiComponents/ParcelInfoPanel/ParcelInfoPanel.tsx(630,44): error TS2304: Cannot find name 't'.
|
||||
src/components/UiComponents/ParcelInfoPanel/ParcelInfoPanel.tsx(648,38): error TS2304: Cannot find name 't'.
|
||||
src/components/UiComponents/ParcelInfoPanel/ParcelInfoPanel.tsx(672,73): error TS2304: Cannot find name 't'.
|
||||
src/components/UiComponents/ParcelInfoPanel/ParcelInfoPanel.tsx(689,73): error TS2304: Cannot find name 't'.
|
||||
src/components/UiComponents/ParcelInfoPanel/ParcelInfoPanel.tsx(708,73): error TS2304: Cannot find name 't'.
|
||||
src/components/UiComponents/ParcelInfoPanel/ParcelInfoPanel.tsx(757,74): error TS2304: Cannot find name 't'.
|
||||
src/components/UiComponents/ParcelInfoPanel/ParcelInfoPanel.tsx(844,47): error TS2304: Cannot find name 't'.
|
||||
src/components/UiComponents/ParcelInfoPanel/ParcelInfoPanel.tsx(1083,47): error TS2304: Cannot find name 't'.
|
||||
src/components/UiComponents/Toast/Toast.tsx(23,11): error TS6133: 't' is declared but its value is never read.
|
||||
src/components/UiComponents/Toast/Toast.tsx(57,21): error TS2304: Cannot find name 't'.
|
||||
src/components/UiComponents/WorkflowStatus/WorkflowStatus.tsx(36,11): error TS6133: 't' is declared but its value is never read.
|
||||
src/components/UiComponents/WorkflowStatus/WorkflowStatus.tsx(73,55): error TS2304: Cannot find name 't'.
|
||||
src/components/UnifiedDataBar/ChatsTab.tsx(6,1): error TS6133: 'useLanguage' is declared but its value is never read.
|
||||
src/components/UnifiedDataBar/ChatsTab.tsx(311,26): error TS2304: Cannot find name 't'.
|
||||
src/components/UnifiedDataBar/ChatsTab.tsx(333,56): error TS2304: Cannot find name 't'.
|
||||
src/components/UnifiedDataBar/ChatsTab.tsx(341,24): error TS2304: Cannot find name 't'.
|
||||
src/components/UnifiedDataBar/ChatsTab.tsx(346,119): error TS2304: Cannot find name 't'.
|
||||
src/components/UnifiedDataBar/ChatsTab.tsx(438,36): error TS2304: Cannot find name 't'.
|
||||
src/components/UnifiedDataBar/ChatsTab.tsx(438,75): error TS2304: Cannot find name 't'.
|
||||
src/components/UnifiedDataBar/FilesTab.tsx(9,1): error TS6133: 'useLanguage' is declared but its value is never read.
|
||||
src/components/UnifiedDataBar/FilesTab.tsx(235,56): error TS2304: Cannot find name 't'.
|
||||
src/components/UnifiedDataBar/FilesTab.tsx(263,20): error TS2304: Cannot find name 't'.
|
||||
src/components/UnifiedDataBar/FilesTab.tsx(286,22): error TS2304: Cannot find name 't'.
|
||||
src/components/UnifiedDataBar/FilesTab.tsx(327,28): error TS2304: Cannot find name 't'.
|
||||
src/components/UnifiedDataBar/FilesTab.tsx(327,65): error TS2304: Cannot find name 't'.
|
||||
src/components/UnifiedDataBar/SourcesTab.tsx(23,1): error TS6133: 'useLanguage' is declared but its value is never read.
|
||||
src/components/UnifiedDataBar/SourcesTab.tsx(855,42): error TS2304: Cannot find name 't'.
|
||||
src/components/UnifiedDataBar/SourcesTab.tsx(855,90): error TS2304: Cannot find name 't'.
|
||||
src/components/UnifiedDataBar/SourcesTab.tsx(862,26): error TS2304: Cannot find name 't'.
|
||||
src/components/UnifiedDataBar/SourcesTab.tsx(958,32): error TS2304: Cannot find name 't'.
|
||||
src/components/UnifiedDataBar/SourcesTab.tsx(988,53): error TS2304: Cannot find name 't'.
|
||||
src/components/UnifiedDataBar/SourcesTab.tsx(988,84): error TS2304: Cannot find name 't'.
|
||||
src/components/UnifiedDataBar/SourcesTab.tsx(995,36): error TS2304: Cannot find name 't'.
|
||||
src/components/UnifiedDataBar/SourcesTab.tsx(1031,49): error TS2304: Cannot find name 't'.
|
||||
src/components/UnifiedDataBar/SourcesTab.tsx(1031,80): error TS2304: Cannot find name 't'.
|
||||
src/components/UnifiedDataBar/SourcesTab.tsx(1038,32): error TS2304: Cannot find name 't'.
|
||||
src/components/UnifiedDataBar/SourcesTab.tsx(1168,20): error TS2304: Cannot find name 't'.
|
||||
src/components/UnifiedDataBar/SourcesTab.tsx(1174,82): error TS2304: Cannot find name 't'.
|
||||
src/components/UnifiedDataBar/SourcesTab.tsx(1431,18): error TS2304: Cannot find name 't'.
|
||||
src/components/UnifiedDataBar/SourcesTab.tsx(1437,80): error TS2304: Cannot find name 't'.
|
||||
src/components/UnifiedDataBar/SourcesTab.tsx(1577,20): error TS2304: Cannot find name 't'.
|
||||
src/components/UnifiedDataBar/SourcesTab.tsx(1583,82): error TS2304: Cannot find name 't'.
|
||||
src/hooks/usePlayground.ts(2,3): error TS2305: Module '"../api/workflowApi"' has no exported member 'Workflow'.
|
||||
src/hooks/usePlayground.ts(3,3): error TS2305: Module '"../api/workflowApi"' has no exported member 'WorkflowMessage'.
|
||||
src/hooks/usePlayground.ts(4,3): error TS2305: Module '"../api/workflowApi"' has no exported member 'WorkflowLog'.
|
||||
src/hooks/usePlayground.ts(5,3): error TS2305: Module '"../api/workflowApi"' has no exported member 'StartWorkflowRequest'.
|
||||
src/hooks/usePlayground.ts(6,3): error TS2305: Module '"../api/workflowApi"' has no exported member 'StartWorkflowResponse'.
|
||||
src/hooks/usePlayground.ts(7,3): error TS2305: Module '"../api/workflowApi"' has no exported member 'ChatDataResponse'.
|
||||
src/hooks/useWorkflows.ts(4,3): error TS2724: '"../api/workflowApi"' has no exported member named 'deleteWorkflowApi'. Did you mean 'deleteWorkflow'?
|
||||
src/hooks/useWorkflows.ts(5,3): error TS2724: '"../api/workflowApi"' has no exported member named 'deleteWorkflowsApi'. Did you mean 'deleteWorkflow'?
|
||||
src/hooks/useWorkflows.ts(6,3): error TS2724: '"../api/workflowApi"' has no exported member named 'updateWorkflowApi'. Did you mean 'updateWorkflow'?
|
||||
src/hooks/useWorkflows.ts(9,3): error TS2305: Module '"../api/workflowApi"' has no exported member 'fetchAttributes'.
|
||||
src/hooks/useWorkflows.ts(10,3): error TS2305: Module '"../api/workflowApi"' has no exported member 'startWorkflowApi'.
|
||||
src/hooks/useWorkflows.ts(11,3): error TS2305: Module '"../api/workflowApi"' has no exported member 'stopWorkflowApi'.
|
||||
src/hooks/useWorkflows.ts(12,3): error TS2305: Module '"../api/workflowApi"' has no exported member 'deleteMessageApi'.
|
||||
src/hooks/useWorkflows.ts(13,3): error TS2305: Module '"../api/workflowApi"' has no exported member 'deleteFileFromMessageApi'.
|
||||
src/hooks/useWorkflows.ts(14,8): error TS2305: Module '"../api/workflowApi"' has no exported member 'Workflow'.
|
||||
src/hooks/useWorkflows.ts(15,8): error TS2305: Module '"../api/workflowApi"' has no exported member 'AttributeDefinition'.
|
||||
src/hooks/useWorkflows.ts(16,8): error TS2305: Module '"../api/workflowApi"' has no exported member 'StartWorkflowRequest'.
|
||||
src/hooks/useWorkflows.ts(32,15): error TS2305: Module '"../api/workflowApi"' has no exported member 'AttributeDefinition'.
|
||||
src/hooks/useWorkflows.ts(132,49): error TS2345: Argument of type 'undefined' is not assignable to parameter of type 'string'.
|
||||
src/hooks/useWorkflows.ts(195,72): error TS2345: Argument of type 'string | undefined' is not assignable to parameter of type 'string'.
|
||||
Type 'undefined' is not assignable to type 'string'.
|
||||
src/hooks/useWorkflows.ts(196,14): error TS2352: Conversion of type 'Automation2Workflow' to type 'UserWorkflow' may be a mistake because neither type sufficiently overlaps with the other. If this was intentional, convert the expression to 'unknown' first.
|
||||
Type 'Automation2Workflow' is missing the following properties from type 'UserWorkflow': mandateId, status
|
||||
src/hooks/useWorkflows.ts(247,40): error TS7006: Parameter 'opt' implicitly has an 'any' type.
|
||||
src/hooks/useWorkflows.ts(263,40): error TS7006: Parameter 'opt' implicitly has an 'any' type.
|
||||
src/layouts/FeatureLayout.tsx(23,9): error TS2304: Cannot find name 't'.
|
||||
src/layouts/FeatureLayout.tsx(39,10): error TS2304: Cannot find name 't'.
|
||||
src/layouts/FeatureLayout.tsx(62,11): error TS6133: 't' is declared but its value is never read.
|
||||
src/pages/admin/AdminFeatureInstanceUsersPage.tsx(13,26): error TS6133: 'FaUsers' is declared but its value is never read.
|
||||
src/pages/admin/AdminInvitationsPage.tsx(13,26): error TS6133: 'FaEnvelopeOpenText' is declared but its value is never read.
|
||||
src/pages/admin/AdminMandatesPage.tsx(20,26): error TS6133: 'FaBuilding' is declared but its value is never read.
|
||||
src/pages/admin/AdminUserMandatesPage.tsx(12,26): error TS6133: 'FaUsers' is declared but its value is never read.
|
||||
src/pages/admin/AdminUsersPage.tsx(12,26): error TS6133: 'FaUsers' is declared but its value is never read.
|
||||
src/pages/basedata/ConnectionsPage.tsx(12,18): error TS6133: 'FaPlug' is declared but its value is never read.
|
||||
src/pages/basedata/FilesPage.tsx(17,18): error TS6133: 'FaFolder' is declared but its value is never read.
|
||||
src/pages/billing/BillingDashboard.tsx(12,1): error TS6133: 'useLanguage' is declared but its value is never read.
|
||||
src/pages/billing/BillingDashboard.tsx(69,56): error TS2304: Cannot find name 't'.
|
||||
src/pages/billing/BillingDashboard.tsx(73,44): error TS2304: Cannot find name 't'.
|
||||
src/pages/billing/BillingDashboard.tsx(87,14): error TS2304: Cannot find name 't'.
|
||||
src/pages/billing/BillingDashboard.tsx(89,43): error TS2304: Cannot find name 't'.
|
||||
src/pages/billing/BillingDashboard.tsx(109,14): error TS2304: Cannot find name 't'.
|
||||
src/pages/billing/BillingDashboard.tsx(111,43): error TS2304: Cannot find name 't'.
|
||||
src/pages/billing/BillingDashboard.tsx(131,14): error TS2304: Cannot find name 't'.
|
||||
src/pages/billing/BillingDashboard.tsx(133,43): error TS2304: Cannot find name 't'.
|
||||
src/pages/billing/BillingDashboard.tsx(189,14): error TS2304: Cannot find name 't'.
|
||||
src/pages/billing/BillingDashboard.tsx(190,41): error TS2304: Cannot find name 't'.
|
||||
src/pages/billing/BillingDashboard.tsx(197,46): error TS2304: Cannot find name 't'.
|
||||
src/pages/billing/BillingDashboard.tsx(199,55): error TS2304: Cannot find name 't'.
|
||||
src/pages/billing/BillingDashboard.tsx(201,43): error TS2304: Cannot find name 't'.
|
||||
src/pages/billing/BillingMandateView.tsx(19,1): error TS6133: 'useLanguage' is declared but its value is never read.
|
||||
src/pages/billing/BillingMandateView.tsx(47,18): error TS2304: Cannot find name 't'.
|
||||
src/pages/billing/BillingMandateView.tsx(48,18): error TS2304: Cannot find name 't'.
|
||||
src/pages/billing/BillingMandateView.tsx(49,18): error TS2304: Cannot find name 't'.
|
||||
src/pages/billing/BillingMandateView.tsx(72,62): error TS2304: Cannot find name 't'.
|
||||
src/pages/billing/BillingMandateView.tsx(72,89): error TS2304: Cannot find name 't'.
|
||||
src/pages/billing/BillingMandateView.tsx(134,18): error TS2304: Cannot find name 't'.
|
||||
src/pages/billing/BillingMandateView.tsx(136,18): error TS2304: Cannot find name 't'.
|
||||
src/pages/billing/BillingMandateView.tsx(140,49): error TS2304: Cannot find name 't'.
|
||||
src/pages/billing/BillingMandateView.tsx(218,45): error TS2304: Cannot find name 't'.
|
||||
src/pages/billing/BillingMandateView.tsx(229,55): error TS2304: Cannot find name 't'.
|
||||
src/pages/billing/BillingMandateView.tsx(231,43): error TS2304: Cannot find name 't'.
|
||||
src/pages/billing/BillingMandateView.tsx(254,55): error TS2304: Cannot find name 't'.
|
||||
src/pages/billing/BillingMandateView.tsx(256,43): error TS2304: Cannot find name 't'.
|
||||
src/pages/billing/BillingMandateView.tsx(268,30): error TS2304: Cannot find name 't'.
|
||||
src/pages/billing/BillingMandateView.tsx(268,58): error TS2304: Cannot find name 't'.
|
||||
src/pages/billing/BillingTransactions.tsx(12,1): error TS6133: 'useLanguage' is declared but its value is never read.
|
||||
src/pages/billing/BillingTransactions.tsx(98,14): error TS2304: Cannot find name 't'.
|
||||
src/pages/billing/BillingTransactions.tsx(99,41): error TS2304: Cannot find name 't'.
|
||||
src/pages/billing/BillingTransactions.tsx(106,55): error TS2304: Cannot find name 't'.
|
||||
src/pages/billing/BillingTransactions.tsx(108,43): error TS2304: Cannot find name 't'.
|
||||
src/pages/billing/BillingTransactions.tsx(116,26): error TS2304: Cannot find name 't'.
|
||||
src/pages/billing/BillingTransactions.tsx(118,26): error TS2304: Cannot find name 't'.
|
||||
src/pages/billing/BillingTransactions.tsx(122,57): error TS2304: Cannot find name 't'.
|
||||
src/pages/billing/BillingTransactions.tsx(140,30): error TS2304: Cannot find name 't'.
|
||||
src/pages/billing/BillingTransactions.tsx(140,63): error TS2304: Cannot find name 't'.
|
||||
src/pages/billing/BillingUserView.tsx(20,1): error TS6133: 'useLanguage' is declared but its value is never read.
|
||||
src/pages/billing/BillingUserView.tsx(93,31): error TS2304: Cannot find name 't'.
|
||||
src/pages/billing/BillingUserView.tsx(108,31): error TS2304: Cannot find name 't'.
|
||||
src/pages/billing/BillingUserView.tsx(121,20): error TS2304: Cannot find name 't'.
|
||||
src/pages/billing/BillingUserView.tsx(122,20): error TS2304: Cannot find name 't'.
|
||||
src/pages/billing/BillingUserView.tsx(123,51): error TS2304: Cannot find name 't'.
|
||||
src/pages/billing/BillingUserView.tsx(125,20): error TS2304: Cannot find name 't'.
|
||||
src/pages/billing/BillingUserView.tsx(144,70): error TS2304: Cannot find name 't'.
|
||||
src/pages/billing/BillingUserView.tsx(227,18): error TS2304: Cannot find name 't'.
|
||||
src/pages/billing/BillingUserView.tsx(228,18): error TS2304: Cannot find name 't'.
|
||||
src/pages/billing/BillingUserView.tsx(230,18): error TS2304: Cannot find name 't'.
|
||||
src/pages/billing/BillingUserView.tsx(234,49): error TS2304: Cannot find name 't'.
|
||||
src/pages/billing/BillingUserView.tsx(314,41): error TS2304: Cannot find name 't'.
|
||||
src/pages/billing/BillingUserView.tsx(323,55): error TS2304: Cannot find name 't'.
|
||||
src/pages/billing/BillingUserView.tsx(325,43): error TS2304: Cannot find name 't'.
|
||||
src/pages/billing/BillingUserView.tsx(350,55): error TS2304: Cannot find name 't'.
|
||||
src/pages/billing/BillingUserView.tsx(352,43): error TS2304: Cannot find name 't'.
|
||||
src/pages/billing/BillingUserView.tsx(368,30): error TS2304: Cannot find name 't'.
|
||||
src/pages/billing/BillingUserView.tsx(368,55): error TS2304: Cannot find name 't'.
|
||||
src/pages/billing/SubscriptionTab.tsx(413,11): error TS2304: Cannot find name 't'.
|
||||
src/pages/billing/SubscriptionTab.tsx(414,11): error TS2304: Cannot find name 't'.
|
||||
src/pages/billing/SubscriptionTab.tsx(416,39): error TS2304: Cannot find name 't'.
|
||||
src/pages/billing/SubscriptionTab.tsx(416,79): error TS2304: Cannot find name 't'.
|
||||
src/pages/billing/SubscriptionTab.tsx(417,46): error TS2304: Cannot find name 't'.
|
||||
src/pages/billing/SubscriptionTab.tsx(417,81): error TS2304: Cannot find name 't'.
|
||||
src/pages/billing/SubscriptionTab.tsx(418,45): error TS2304: Cannot find name 't'.
|
||||
src/pages/billing/SubscriptionTab.tsx(418,80): error TS2304: Cannot find name 't'.
|
||||
src/pages/billing/SubscriptionTab.tsx(448,56): error TS2304: Cannot find name 't'.
|
||||
src/pages/billing/SubscriptionTab.tsx(475,46): error TS2304: Cannot find name 't'.
|
||||
src/pages/billing/SubscriptionTab.tsx(481,29): error TS2304: Cannot find name 't'.
|
||||
src/pages/billing/SubscriptionTab.tsx(481,75): error TS2304: Cannot find name 't'.
|
||||
src/pages/billing/SubscriptionTab.tsx(499,48): error TS2304: Cannot find name 't'.
|
||||
src/pages/billing/SubscriptionTab.tsx(503,20): error TS2304: Cannot find name 't'.
|
||||
src/pages/billing/SubscriptionTab.tsx(513,46): error TS2304: Cannot find name 't'.
|
||||
src/pages/billing/SubscriptionTab.tsx(515,43): error TS2304: Cannot find name 't'.
|
||||
src/pages/Dashboard.tsx(18,1): error TS6133: 'useLanguage' is declared but its value is never read.
|
||||
src/pages/Dashboard.tsx(74,16): error TS2304: Cannot find name 't'.
|
||||
src/pages/Dashboard.tsx(75,43): error TS2304: Cannot find name 't'.
|
||||
src/pages/Dashboard.tsx(84,14): error TS2304: Cannot find name 't'.
|
||||
src/pages/FeatureView.tsx(54,1): error TS6133: 'useLanguage' is declared but its value is never read.
|
||||
src/pages/FeatureView.tsx(69,27): error TS2304: Cannot find name 't'.
|
||||
src/pages/FeatureView.tsx(69,72): error TS2304: Cannot find name 't'.
|
||||
src/pages/FeatureView.tsx(73,46): error TS2304: Cannot find name 't'.
|
||||
src/pages/FeatureView.tsx(77,27): error TS2304: Cannot find name 't'.
|
||||
src/pages/FeatureView.tsx(84,27): error TS2304: Cannot find name 't'.
|
||||
src/pages/FeatureView.tsx(84,75): error TS2304: Cannot find name 't'.
|
||||
src/pages/FeatureView.tsx(90,10): error TS2304: Cannot find name 't'.
|
||||
src/pages/FeatureView.tsx(91,9): error TS2304: Cannot find name 't'.
|
||||
src/pages/FeatureView.tsx(97,10): error TS2304: Cannot find name 't'.
|
||||
src/pages/FeatureView.tsx(98,9): error TS2304: Cannot find name 't'.
|
||||
src/pages/GDPR.tsx(29,11): error TS6133: 't' is declared but its value is never read.
|
||||
src/pages/GDPR.tsx(165,48): error TS2304: Cannot find name 't'.
|
||||
src/pages/GDPR.tsx(168,20): error TS2304: Cannot find name 't'.
|
||||
src/pages/GDPR.tsx(169,19): error TS2304: Cannot find name 't'.
|
||||
src/pages/GDPR.tsx(190,20): error TS2304: Cannot find name 't'.
|
||||
src/pages/GDPR.tsx(191,19): error TS2304: Cannot find name 't'.
|
||||
src/pages/GDPR.tsx(212,20): error TS2304: Cannot find name 't'.
|
||||
src/pages/GDPR.tsx(213,19): error TS2304: Cannot find name 't'.
|
||||
src/pages/GDPR.tsx(282,48): error TS2304: Cannot find name 't'.
|
||||
src/pages/GDPR.tsx(283,65): error TS2304: Cannot find name 't'.
|
||||
src/pages/GDPR.tsx(288,22): error TS2304: Cannot find name 't'.
|
||||
src/pages/GDPR.tsx(298,22): error TS2304: Cannot find name 't'.
|
||||
src/pages/GDPR.tsx(308,22): error TS2304: Cannot find name 't'.
|
||||
src/pages/Register.tsx(47,11): error TS6133: 't' is declared but its value is never read.
|
||||
src/pages/Register.tsx(142,26): error TS2304: Cannot find name 't'.
|
||||
src/pages/Register.tsx(195,115): error TS2304: Cannot find name 't'.
|
||||
src/pages/Register.tsx(199,25): error TS2304: Cannot find name 't'.
|
||||
src/pages/Register.tsx(219,24): error TS2304: Cannot find name 't'.
|
||||
src/pages/Reset.tsx(40,11): error TS6133: 't' is declared but its value is never read.
|
||||
src/pages/Reset.tsx(111,45): error TS2304: Cannot find name 't'.
|
||||
src/pages/Reset.tsx(123,26): error TS2304: Cannot find name 't'.
|
||||
src/pages/Reset.tsx(151,43): error TS2304: Cannot find name 't'.
|
||||
src/pages/Reset.tsx(163,57): error TS2304: Cannot find name 't'.
|
||||
src/pages/Reset.tsx(178,106): error TS2304: Cannot find name 't'.
|
||||
src/pages/Reset.tsx(196,120): error TS2304: Cannot find name 't'.
|
||||
src/pages/Reset.tsx(210,24): error TS2304: Cannot find name 't'.
|
||||
src/pages/Store.tsx(140,38): error TS2304: Cannot find name 't'.
|
||||
src/pages/Store.tsx(140,65): error TS2304: Cannot find name 't'.
|
||||
src/pages/views/commcoach/CommcoachDossierView.tsx(32,1): error TS6133: 'useLanguage' is declared but its value is never read.
|
||||
src/pages/views/commcoach/CommcoachDossierView.tsx(252,46): error TS2304: Cannot find name 't'.
|
||||
src/pages/views/commcoach/CommcoachDossierView.tsx(263,35): error TS2304: Cannot find name 't'.
|
||||
src/pages/views/commcoach/CommcoachDossierView.tsx(263,82): error TS2304: Cannot find name 't'.
|
||||
src/pages/views/commcoach/CommcoachDossierView.tsx(295,18): error TS2304: Cannot find name 't'.
|
||||
src/pages/views/commcoach/CommcoachDossierView.tsx(306,26): error TS2304: Cannot find name 't'.
|
||||
src/pages/views/commcoach/CommcoachDossierView.tsx(314,26): error TS2304: Cannot find name 't'.
|
||||
src/pages/views/commcoach/CommcoachDossierView.tsx(320,41): error TS2304: Cannot find name 't'.
|
||||
src/pages/views/commcoach/CommcoachDossierView.tsx(323,43): error TS2304: Cannot find name 't'.
|
||||
src/pages/views/commcoach/CommcoachDossierView.tsx(326,47): error TS2304: Cannot find name 't'.
|
||||
src/pages/views/commcoach/CommcoachDossierView.tsx(330,53): error TS2304: Cannot find name 't'.
|
||||
src/pages/views/commcoach/CommcoachDossierView.tsx(330,90): error TS2304: Cannot find name 't'.
|
||||
src/pages/views/commcoach/CommcoachDossierView.tsx(332,95): error TS2304: Cannot find name 't'.
|
||||
src/pages/views/commcoach/CommcoachDossierView.tsx(340,16): error TS2304: Cannot find name 't'.
|
||||
src/pages/views/commcoach/CommcoachDossierView.tsx(341,15): error TS2304: Cannot find name 't'.
|
||||
src/pages/views/commcoach/CommcoachDossierView.tsx(342,90): error TS2304: Cannot find name 't'.
|
||||
src/pages/views/commcoach/CommcoachDossierView.tsx(358,162): error TS2304: Cannot find name 't'.
|
||||
src/pages/views/commcoach/CommcoachDossierView.tsx(359,163): error TS2304: Cannot find name 't'.
|
||||
src/pages/views/commcoach/CommcoachDossierView.tsx(363,54): error TS2304: Cannot find name 't'.
|
||||
src/pages/views/commcoach/CommcoachDossierView.tsx(363,93): error TS2304: Cannot find name 't'.
|
||||
src/pages/views/commcoach/CommcoachDossierView.tsx(376,38): error TS2304: Cannot find name 't'.
|
||||
src/pages/views/commcoach/CommcoachDossierView.tsx(388,21): error TS2304: Cannot find name 't'.
|
||||
src/pages/views/commcoach/CommcoachDossierView.tsx(391,61): error TS2304: Cannot find name 't'.
|
||||
src/pages/views/commcoach/CommcoachDossierView.tsx(419,58): error TS2304: Cannot find name 't'.
|
||||
src/pages/views/commcoach/CommcoachDossierView.tsx(433,44): error TS2304: Cannot find name 't'.
|
||||
src/pages/views/commcoach/CommcoachDossierView.tsx(433,91): error TS2304: Cannot find name 't'.
|
||||
src/pages/views/commcoach/CommcoachDossierView.tsx(435,38): error TS2304: Cannot find name 't'.
|
||||
src/pages/views/commcoach/CommcoachDossierView.tsx(435,74): error TS2304: Cannot find name 't'.
|
||||
src/pages/views/commcoach/CommcoachDossierView.tsx(438,63): error TS2304: Cannot find name 't'.
|
||||
src/pages/views/commcoach/CommcoachDossierView.tsx(438,105): error TS2304: Cannot find name 't'.
|
||||
src/pages/views/commcoach/CommcoachDossierView.tsx(441,63): error TS2304: Cannot find name 't'.
|
||||
src/pages/views/commcoach/CommcoachDossierView.tsx(441,103): error TS2304: Cannot find name 't'.
|
||||
src/pages/views/commcoach/CommcoachDossierView.tsx(490,86): error TS2304: Cannot find name 't'.
|
||||
src/pages/views/commcoach/CommcoachDossierView.tsx(490,131): error TS2304: Cannot find name 't'.
|
||||
src/pages/views/commcoach/CommcoachDossierView.tsx(603,36): error TS2304: Cannot find name 't'.
|
||||
src/pages/views/commcoach/CommcoachDossierView.tsx(617,34): error TS2304: Cannot find name 't'.
|
||||
src/pages/views/commcoach/CommcoachDossierView.tsx(642,148): error TS2304: Cannot find name 't'.
|
||||
src/pages/views/commcoach/CommcoachDossierView.tsx(672,34): error TS2304: Cannot find name 't'.
|
||||
src/pages/views/commcoach/CommcoachDossierView.tsx(699,152): error TS2304: Cannot find name 't'.
|
||||
src/pages/views/commcoach/CommcoachDossierView.tsx(776,30): error TS2304: Cannot find name 't'.
|
||||
src/pages/views/commcoach/CommcoachDossierView.tsx(782,57): error TS2304: Cannot find name 't'.
|
||||
src/pages/views/commcoach/CommcoachDossierView.tsx(782,98): error TS2304: Cannot find name 't'.
|
||||
src/pages/views/commcoach/CommcoachDossierView.tsx(786,49): error TS2304: Cannot find name 't'.
|
||||
src/pages/views/commcoach/CommcoachDossierView.tsx(815,49): error TS2304: Cannot find name 't'.
|
||||
src/pages/views/commcoach/CommcoachDossierView.tsx(822,95): error TS2304: Cannot find name 't'.
|
||||
src/pages/views/commcoach/CommcoachDossierView.tsx(822,125): error TS2304: Cannot find name 't'.
|
||||
src/pages/views/commcoach/CommcoachDossierView.tsx(832,47): error TS2304: Cannot find name 't'.
|
||||
src/pages/views/commcoach/CommcoachDossierView.tsx(850,49): error TS2304: Cannot find name 't'.
|
||||
src/pages/views/graphicalEditor/GraphicalEditorWorkflowsTasksPage.tsx(120,29): error TS2339: Property 'filter' does not exist on type 'Automation2Workflow[] | { items: Automation2Workflow[]; pagination: any; }'.
|
||||
Property 'filter' does not exist on type '{ items: Automation2Workflow[]; pagination: any; }'.
|
||||
src/pages/views/graphicalEditor/GraphicalEditorWorkflowsTasksPage.tsx(121,14): error TS7006: Parameter 'w' implicitly has an 'any' type.
|
||||
src/pages/views/graphicalEditor/GraphicalEditorWorkflowsTasksPage.tsx(376,47): error TS2304: Cannot find name 't'.
|
||||
src/pages/views/graphicalEditor/GraphicalEditorWorkflowsTasksPage.tsx(391,38): error TS2304: Cannot find name 't'.
|
||||
src/pages/views/graphicalEditor/GraphicalEditorWorkflowsTasksPage.tsx(476,83): error TS2304: Cannot find name 't'.
|
||||
src/pages/views/graphicalEditor/GraphicalEditorWorkflowsTasksPage.tsx(486,29): error TS2304: Cannot find name 't'.
|
||||
src/pages/views/graphicalEditor/GraphicalEditorWorkflowsTasksPage.tsx(600,39): error TS2304: Cannot find name 't'.
|
||||
src/pages/views/graphicalEditor/GraphicalEditorWorkflowsTasksPage.tsx(634,22): error TS2304: Cannot find name 't'.
|
||||
src/pages/views/graphicalEditor/GraphicalEditorWorkflowsTasksPage.tsx(648,33): error TS2304: Cannot find name 't'.
|
||||
src/pages/views/graphicalEditor/GraphicalEditorWorkflowsTasksPage.tsx(648,83): error TS2304: Cannot find name 't'.
|
||||
src/pages/views/graphicalEditor/GraphicalEditorWorkflowsTasksPage.tsx(855,41): error TS2304: Cannot find name 't'.
|
||||
src/pages/views/graphicalEditor/GraphicalEditorWorkflowsTasksPage.tsx(855,94): error TS2304: Cannot find name 't'.
|
||||
src/pages/views/graphicalEditor/GraphicalEditorWorkflowsTasksPage.tsx(871,29): error TS2304: Cannot find name 't'.
|
||||
src/pages/views/graphicalEditor/GraphicalEditorWorkflowsTasksPage.tsx(871,79): error TS2304: Cannot find name 't'.
|
||||
src/pages/views/graphicalEditor/GraphicalEditorWorkflowsTasksPage.tsx(879,17): error TS2304: Cannot find name 't'.
|
||||
src/pages/views/graphicalEditor/GraphicalEditorWorkflowsTasksPage.tsx(920,47): error TS2304: Cannot find name 't'.
|
||||
src/pages/views/graphicalEditor/GraphicalEditorWorkflowsTasksPage.tsx(926,47): error TS2304: Cannot find name 't'.
|
||||
src/pages/views/neutralization/NeutralizationView.tsx(368,58): error TS2304: Cannot find name 't'.
|
||||
src/pages/views/neutralization/NeutralizationView.tsx(368,110): error TS2304: Cannot find name 't'.
|
||||
src/pages/views/neutralization/NeutralizationView.tsx(485,44): error TS2304: Cannot find name 't'.
|
||||
src/pages/views/neutralization/NeutralizationView.tsx(492,19): error TS2304: Cannot find name 't'.
|
||||
src/pages/views/neutralization/NeutralizationView.tsx(507,31): error TS2304: Cannot find name 't'.
|
||||
src/pages/views/neutralization/NeutralizationView.tsx(507,64): error TS2304: Cannot find name 't'.
|
||||
src/pages/views/neutralization/NeutralizationView.tsx(527,40): error TS2304: Cannot find name 't'.
|
||||
src/pages/views/neutralization/NeutralizationView.tsx(533,26): error TS2304: Cannot find name 't'.
|
||||
src/pages/views/neutralization/NeutralizationView.tsx(557,19): error TS2304: Cannot find name 't'.
|
||||
src/pages/views/neutralization/NeutralizationView.tsx(567,32): error TS2304: Cannot find name 't'.
|
||||
src/pages/views/neutralization/NeutralizationView.tsx(574,46): error TS2304: Cannot find name 't'.
|
||||
src/pages/views/neutralization/NeutralizationView.tsx(574,85): error TS2304: Cannot find name 't'.
|
||||
src/pages/views/neutralization/NeutralizationView.tsx(588,32): error TS2304: Cannot find name 't'.
|
||||
src/pages/views/neutralization/NeutralizationView.tsx(595,46): error TS2304: Cannot find name 't'.
|
||||
src/pages/views/neutralization/NeutralizationView.tsx(595,85): error TS2304: Cannot find name 't'.
|
||||
src/pages/views/neutralization/NeutralizationView.tsx(614,39): error TS2304: Cannot find name 't'.
|
||||
src/pages/views/neutralization/NeutralizationView.tsx(614,72): error TS2304: Cannot find name 't'.
|
||||
src/pages/views/neutralization/NeutralizationView.tsx(621,48): error TS2304: Cannot find name 't'.
|
||||
src/pages/views/neutralization/NeutralizationView.tsx(657,46): error TS2304: Cannot find name 't'.
|
||||
src/pages/views/neutralization/NeutralizationView.tsx(667,35): error TS2304: Cannot find name 't'.
|
||||
src/pages/views/neutralization/NeutralizationView.tsx(682,50): error TS2304: Cannot find name 't'.
|
||||
src/pages/views/workspace/ChatStream.tsx(16,1): error TS6133: 'useLanguage' is declared but its value is never read.
|
||||
src/pages/views/workspace/ChatStream.tsx(377,85): error TS2304: Cannot find name 't'.
|
||||
src/pages/views/workspace/FilePreview.tsx(16,1): error TS6133: 'useLanguage' is declared but its value is never read.
|
||||
src/pages/views/workspace/FilePreview.tsx(102,91): error TS2304: Cannot find name 't'.
|
||||
src/pages/views/workspace/NeutralizationPanel.tsx(4,1): error TS6133: 'useLanguage' is declared but its value is never read.
|
||||
src/pages/views/workspace/NeutralizationPanel.tsx(295,92): error TS2304: Cannot find name 't'.
|
||||
src/pages/views/workspace/NeutralizationPanel.tsx(439,26): error TS2304: Cannot find name 't'.
|
||||
src/pages/views/workspace/NeutralizationPanel.tsx(452,15): error TS2304: Cannot find name 't'.
|
||||
src/pages/views/workspace/NeutralizationPanel.tsx(453,15): error TS2304: Cannot find name 't'.
|
||||
src/pages/views/workspace/WorkspaceGeneralSettings.tsx(11,1): error TS6133: 'useLanguage' is declared but its value is never read.
|
||||
src/pages/views/workspace/WorkspaceGeneralSettings.tsx(69,18): error TS2304: Cannot find name 't'.
|
||||
src/pages/views/workspace/WorkspaceGeneralSettings.tsx(83,18): error TS2304: Cannot find name 't'.
|
||||
src/pages/views/workspace/WorkspaceGeneralSettings.tsx(97,45): error TS2304: Cannot find name 't'.
|
||||
src/pages/views/workspace/WorkspaceGeneralSettings.tsx(104,39): error TS2304: Cannot find name 't'.
|
||||
src/pages/views/workspace/WorkspaceGeneralSettings.tsx(132,24): error TS2304: Cannot find name 't'.
|
||||
src/pages/views/workspace/WorkspaceGeneralSettings.tsx(153,19): error TS2304: Cannot find name 't'.
|
||||
src/pages/views/workspace/WorkspaceGeneralSettings.tsx(153,61): error TS2304: Cannot find name 't'.
|
||||
|
|
@ -28,5 +28,10 @@
|
|||
"src/**/*.tsx", // Include all .tsx files
|
||||
"src/**/*.d.ts", // Include all declaration files
|
||||
"src/global.d.ts"
|
||||
],
|
||||
"exclude": [
|
||||
"src/**/*.test.ts",
|
||||
"src/**/*.test.tsx",
|
||||
"src/test/**"
|
||||
]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.app.json" },
|
||||
{ "path": "./tsconfig.node.json" }
|
||||
{ "path": "./tsconfig.node.json" },
|
||||
{ "path": "./tsconfig.test.json" }
|
||||
]
|
||||
}
|
||||
|
|
|
|||
15
tsconfig.test.json
Normal file
15
tsconfig.test.json
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
{
|
||||
"extends": "./tsconfig.app.json",
|
||||
"compilerOptions": {
|
||||
"types": ["vitest/globals", "@testing-library/jest-dom", "node"],
|
||||
"noEmit": true,
|
||||
"noUnusedLocals": false,
|
||||
"noUnusedParameters": false
|
||||
},
|
||||
"include": [
|
||||
"src/**/*.test.ts",
|
||||
"src/**/*.test.tsx",
|
||||
"src/test/**/*.ts",
|
||||
"vitest.config.ts"
|
||||
]
|
||||
}
|
||||
26
vitest.config.ts
Normal file
26
vitest.config.ts
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
// Copyright (c) 2025 Patrick Motsch
|
||||
// All rights reserved.
|
||||
//
|
||||
// Vitest config for frontend unit + component tests.
|
||||
// Lives next to vite.config.ts; reuses the @vitejs/plugin-react setup.
|
||||
|
||||
import { defineConfig } from 'vitest/config';
|
||||
import react from '@vitejs/plugin-react';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
test: {
|
||||
environment: 'jsdom',
|
||||
globals: true,
|
||||
setupFiles: ['./src/test/setup.ts'],
|
||||
css: true,
|
||||
include: ['src/**/*.test.{ts,tsx}'],
|
||||
exclude: ['node_modules', 'dist', '.git'],
|
||||
coverage: {
|
||||
provider: 'v8',
|
||||
reporter: ['text', 'html'],
|
||||
include: ['src/**/*.{ts,tsx}'],
|
||||
exclude: ['src/**/*.test.{ts,tsx}', 'src/test/**', 'src/main.tsx'],
|
||||
},
|
||||
},
|
||||
});
|
||||
Loading…
Reference in a new issue