Merge pull request #58 from valueonag/feat/demo-system-readieness

Feat/demo system readieness
This commit is contained in:
Patrick Motsch 2026-04-26 22:54:40 +02:00 committed by GitHub
commit 2994f3a090
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
97 changed files with 8573 additions and 2304 deletions

1626
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -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"
}
}

View file

@ -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

View file

@ -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;

View file

@ -29,7 +29,7 @@ export interface BillingTransaction {
aicoreProvider?: string;
aicoreModel?: string;
createdByUserId?: string;
createdAt?: string;
sysCreatedAt?: string;
mandateId?: string;
mandateName?: string;
userId?: string;

View file

@ -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;
}

View file

@ -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(

View file

@ -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[] };
}
/**

View file

@ -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}>

View file

@ -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 .nodeConfigPanels 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;

View file

@ -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>
)}

View 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();
});
});

View file

@ -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

View file

@ -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}

View file

@ -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;

View file

@ -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>
)}

View file

@ -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')}

View file

@ -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>
);
};

View file

@ -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;

View file

@ -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;

View 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',
}),
);
});
});

View file

@ -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);
};

View file

@ -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';
}

View file

@ -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' }),
);
});
});

View file

@ -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>
);
};

View file

@ -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 (14)', 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 (14)', 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 };
}

View file

@ -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 */

View file

@ -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,
};
});

View file

@ -69,6 +69,10 @@ export function buildNodeOutputPreview(
return { _transit: true, _meta: {}, data: {} };
}
if (typeof port0.schema !== 'string') {
return {};
}
return _buildSchemaPreview(port0.schema);
}

View 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']]);
});
});

View 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-XX "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);
}

View 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');
}

View file

@ -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 {

View file

@ -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"

View file

@ -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[];

View file

@ -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 {

View file

@ -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
// =============================================================================

View file

@ -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'
>;

View file

@ -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 {

View file

@ -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}`;

View file

@ -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';

View file

@ -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} />

View file

@ -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' }

View file

@ -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 {

View file

@ -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> => {

View file

@ -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,

View file

@ -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={[
{

View file

@ -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(

View file

@ -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')}

View file

@ -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;
}

View file

@ -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>
);
};

View file

@ -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(() => {

View file

@ -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 => ({

View file

@ -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,40 +143,41 @@ export const AdminFeatureRolesPage: React.FC = () => {
return String(value);
};
// Table columns
const columns = useMemo(() => [
{
key: 'roleLabel',
label: t('Rollen-Label'),
type: 'string' as const,
sortable: true,
filterable: true,
searchable: true,
width: 180
const _rawColumns: ColumnConfig[] = useMemo(() => [
{
key: 'roleLabel',
label: t('Rollen-Label'),
sortable: true,
filterable: true,
searchable: true,
width: 180,
},
{
key: 'description',
label: t('Beschreibung'),
type: 'string' as const,
sortable: false,
{
key: 'description',
label: t('Beschreibung'),
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,
{
key: 'featureCode',
label: t('Feature'),
sortable: true,
filterable: true,
width: 120,
formatter: (value: string) => (
<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[] = [

View file

@ -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,
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',
sortable: false,
filterable: false,
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>
);
}
},
{
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: 'currentUses',
label: t('Verwendet'),
type: 'string' as const,
sortable: true,
{ key: 'expiresAt', sortable: true, filterable: true, width: 150 },
{ key: 'expiredFlag', sortable: true, filterable: true, width: 90 },
{
key: 'currentUses',
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>

View file

@ -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',

View file

@ -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
},
{
key: 'description',
label: t('Beschreibung'),
type: 'string' as const,
sortable: false,
const _rawColumns: ColumnConfig[] = useMemo(() => [
{ key: 'roleLabel', sortable: true, filterable: true, searchable: true, width: 150 },
{
key: 'description',
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(() => {

View file

@ -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(() =>

View file

@ -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

View file

@ -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>

View file

@ -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

View file

@ -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';

View file

@ -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

View file

@ -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}

View file

@ -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

View file

@ -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)}`}>

View file

@ -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)}`}>

View file

@ -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>

View file

@ -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}
/>

View file

@ -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);
});
});

View file

@ -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>
);

View file

@ -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',

View file

@ -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',

View file

@ -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
? [

View file

@ -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) }] : []),

View file

@ -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));

View file

@ -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);

View file

@ -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>
);
};

View file

@ -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>
);
};

View file

@ -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 && (
<> {' '}&middot; <strong>{t('Zeitfenster:')}</strong> {timeWindow}</>
<> {' '}&middot; <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}

View file

@ -1,8 +1,15 @@
/**
* TrusteeDocumentsView
*
*
* 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

View file

@ -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

View file

@ -1,8 +1,15 @@
/**
* TrusteePositionsView
*
*
* 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';

View file

@ -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(() => {

View file

@ -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';

View file

@ -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 };

View file

@ -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
View 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
View 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);
});
});

View file

@ -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';
@ -113,6 +116,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
@ -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
*/

View 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;
});
}

View file

@ -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'.

View file

@ -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/**"
]
}

View file

@ -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
View 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
View 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'],
},
},
});