From b61544d8b1dbf99a19676625e89de2d7c33a04f3 Mon Sep 17 00:00:00 2001 From: Ida Date: Wed, 29 Apr 2026 09:16:09 +0200 Subject: [PATCH 1/6] feat: rag extension frontend consent --- src/api/connectionApi.ts | 20 + .../AddConnectionWizard.module.css | 467 ++++++++++++++++ .../AddConnectionWizard.tsx | 521 ++++++++++++++++++ src/hooks/useConnections.ts | 85 +++ src/pages/basedata/ConnectionsPage.module.css | 90 +++ src/pages/basedata/ConnectionsPage.tsx | 123 +++-- 6 files changed, 1256 insertions(+), 50 deletions(-) create mode 100644 src/components/AddConnectionWizard/AddConnectionWizard.module.css create mode 100644 src/components/AddConnectionWizard/AddConnectionWizard.tsx create mode 100644 src/pages/basedata/ConnectionsPage.module.css diff --git a/src/api/connectionApi.ts b/src/api/connectionApi.ts index b93ce33..67d6750 100644 --- a/src/api/connectionApi.ts +++ b/src/api/connectionApi.ts @@ -4,6 +4,22 @@ import { ApiRequestOptions } from '../hooks/useApi'; // TYPES & INTERFACES // ============================================================================ +export interface KnowledgePreferences { + schemaVersion?: number; + neutralizeBeforeEmbed?: boolean; + mailContentDepth?: 'metadata' | 'snippet' | 'full'; + mailIndexAttachments?: boolean; + filesIndexBinaries?: boolean; + mimeAllowlist?: string[]; + clickupScope?: 'titles' | 'title_description' | 'with_comments'; + clickupIndexAttachments?: boolean; + surfaceToggles?: { + google?: { gmail?: boolean; drive?: boolean }; + msft?: { sharepoint?: boolean; outlook?: boolean }; + }; + maxAgeDays?: number; +} + export interface Connection { id: string; userId: string; @@ -15,6 +31,8 @@ export interface Connection { connectedAt: number; // Backend uses float for UTC timestamp in seconds lastChecked: number; // Backend uses float for UTC timestamp in seconds expiresAt?: number; // Backend uses Optional[float] for UTC timestamp in seconds + knowledgeIngestionEnabled?: boolean; + knowledgePreferences?: KnowledgePreferences | null; [key: string]: any; // Allow additional properties } @@ -58,6 +76,8 @@ export interface CreateConnectionData { externalUsername?: string; externalEmail?: string; status?: 'active' | 'expired' | 'revoked' | 'pending'; + knowledgeIngestionEnabled?: boolean; + knowledgePreferences?: KnowledgePreferences | null; connectedAt?: number; lastChecked?: number; expiresAt?: number; diff --git a/src/components/AddConnectionWizard/AddConnectionWizard.module.css b/src/components/AddConnectionWizard/AddConnectionWizard.module.css new file mode 100644 index 0000000..5cabd64 --- /dev/null +++ b/src/components/AddConnectionWizard/AddConnectionWizard.module.css @@ -0,0 +1,467 @@ +/* AddConnectionWizard styles */ + +.stepper { + display: flex; + justify-content: center; + gap: 1.5rem; + padding: 1rem 1.5rem 0; + border-bottom: 1px solid var(--border-color); +} + +.stepDot { + width: 28px; + height: 28px; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + font-size: 0.75rem; + font-weight: 600; + background: var(--bg-secondary, #f0f0f0); + color: var(--text-secondary, #666); + border: 2px solid var(--border-color, #ddd); + transition: background 0.2s, border-color 0.2s, color 0.2s; +} + +.stepDotActive { + background: var(--primary-color, #f25843); + border-color: var(--primary-color, #f25843); + color: white; +} + +.stepDotDone { + background: var(--success-color, #22c55e); + border-color: var(--success-color, #22c55e); + color: white; +} + +.stepDotHidden { + opacity: 0.3; +} + +.body { + padding: 1.5rem; + overflow-y: auto; +} + +.stepContent { + display: flex; + flex-direction: column; + gap: 1rem; + min-height: 220px; +} + +.stepTitle { + font-size: 1.125rem; + font-weight: 600; + color: var(--text-primary); + margin: 0; +} + +.stepBody { + font-size: 0.9375rem; + color: var(--text-primary); + line-height: 1.6; + margin: 0; +} + +.stepHint { + font-size: 0.8125rem; + color: var(--text-secondary, #666); + margin: 0; +} + +/* Connector grid (Step 0) */ +.connectorGrid { + display: flex; + gap: 1rem; + flex-wrap: wrap; +} + +.connectorCard { + flex: 1 1 140px; + display: flex; + flex-direction: column; + align-items: center; + gap: 0.625rem; + padding: 1.25rem 1rem; + background: var(--surface-color); + border: 2px solid var(--border-color, #ddd); + border-radius: 10px; + cursor: pointer; + transition: border-color 0.15s, box-shadow 0.15s, transform 0.1s; +} + +.connectorCard:hover { + border-color: var(--primary-color, #f25843); + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08); + transform: translateY(-2px); +} + +.connectorIcon { + font-size: 1.75rem; +} + +.connectorLabel { + font-size: 0.875rem; + font-weight: 500; + color: var(--text-primary); +} + +/* Consent step (Step 1) */ +.consentIcon { + display: flex; + justify-content: center; + color: var(--primary-color, #f25843); +} + +.consentButtons { + display: flex; + gap: 0.75rem; + flex-wrap: wrap; +} + +.consentButtonYes { + flex: 1; + display: flex; + align-items: center; + justify-content: center; + gap: 0.5rem; + padding: 0.75rem 1.25rem; + background: var(--primary-color, #f25843); + color: white; + border: none; + border-radius: 8px; + font-size: 0.9375rem; + font-weight: 500; + cursor: pointer; + transition: background 0.2s; +} + +.consentButtonYes:hover { + background: var(--primary-dark, #d94d3a); +} + +.consentButtonNo { + flex: 1; + display: flex; + align-items: center; + justify-content: center; + gap: 0.5rem; + padding: 0.75rem 1.25rem; + background: var(--surface-color); + color: var(--text-primary); + border: 2px solid var(--border-color, #ddd); + border-radius: 8px; + font-size: 0.9375rem; + font-weight: 500; + cursor: pointer; + transition: border-color 0.2s, background 0.2s; +} + +.consentButtonNo:hover { + border-color: var(--text-secondary, #888); + background: var(--bg-secondary, #f5f5f5); +} + +/* Preferences step (Step 2) */ +.prefGroup { + display: flex; + flex-direction: column; + gap: 0.25rem; + padding: 0.75rem 0; + border-bottom: 1px solid var(--border-color, #eee); +} + +.prefGroup:last-of-type { + border-bottom: none; +} + +.prefLabel { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.5rem; + font-size: 0.9375rem; + color: var(--text-primary); + cursor: pointer; + font-weight: 500; +} + +.prefLabelRow { + display: flex; + align-items: center; + justify-content: space-between; + gap: 1rem; + font-size: 0.9375rem; + color: var(--text-primary); + font-weight: 500; +} + +.prefIcon { + color: var(--text-secondary, #666); + font-size: 0.875rem; +} + +.prefCheck { + width: 18px; + height: 18px; + cursor: pointer; + accent-color: var(--primary-color, #f25843); +} + +.prefSelect { + padding: 0.375rem 0.5rem; + border: 1px solid var(--border-color, #ddd); + border-radius: 6px; + font-size: 0.875rem; + background: var(--surface-color); + color: var(--text-primary); + min-width: 200px; +} + +.prefNumber { + width: 80px; + padding: 0.375rem 0.5rem; + border: 1px solid var(--border-color, #ddd); + border-radius: 6px; + font-size: 0.875rem; + background: var(--surface-color); + color: var(--text-primary); + text-align: right; +} + +.prefHint { + font-size: 0.8125rem; + color: var(--text-secondary, #666); + margin: 0; +} + +/* Summary step (Step 3) */ +.summary { + display: flex; + flex-direction: column; + gap: 0; + border: 1px solid var(--border-color, #ddd); + border-radius: 8px; + overflow: hidden; +} + +.summaryRow { + display: flex; + justify-content: space-between; + align-items: center; + padding: 0.625rem 1rem; + gap: 1rem; + border-bottom: 1px solid var(--border-color, #eee); +} + +.summaryRow:last-child { + border-bottom: none; +} + +.summaryKey { + font-size: 0.875rem; + color: var(--text-secondary, #666); + font-weight: 500; +} + +.summaryVal { + display: flex; + align-items: center; + gap: 0.375rem; + font-size: 0.875rem; + color: var(--text-primary); + font-weight: 500; +} + +/* Back button (step 1 consent screen) */ +.stepNavLeft { + margin-top: 0.75rem; + display: flex; +} + +.navBack { + background: none; + border: none; + padding: 0.25rem 0; + font-size: 0.8125rem; + color: var(--text-secondary, #666); + cursor: pointer; + text-decoration: underline; +} + +.navBack:hover { + color: var(--text-primary); +} + +/* Cost estimate hint */ +.costHint { + display: flex; + align-items: flex-start; + gap: 0.625rem; + padding: 0.75rem 1rem; + background: var(--info-bg, #eff6ff); + border: 1px solid var(--info-border, #bfdbfe); + border-radius: 8px; + font-size: 0.8125rem; +} + +.costHintIcon { + flex-shrink: 0; + margin-top: 2px; + color: var(--info-color, #3b82f6); +} + +.costHint > div { + display: flex; + flex-direction: column; + gap: 0.25rem; + width: 100%; +} + +.costHintTitle { + font-weight: 600; + color: var(--text-primary); + margin-bottom: 0.125rem; +} + +.costTable { + border-collapse: collapse; + width: 100%; + font-size: 0.8125rem; +} + +.costLabel { + color: var(--text-secondary, #555); + padding-right: 1rem; + white-space: nowrap; +} + +.costVal { + font-weight: 600; + color: var(--info-color, #1d4ed8); +} + +.costRowNeut .costLabel, +.costRowNeut .costVal { + padding-top: 0.125rem; +} + +.costRowNeut .costVal { + color: #b45309; +} + +.costHintWarn { + font-size: 0.75rem; + color: #b45309; + font-weight: 500; + line-height: 1.4; +} + +.costHintNote { + color: var(--text-secondary, #555); + font-size: 0.75rem; +} + +:global(.dark-theme) .costHint { + background: rgba(59, 130, 246, 0.08); + border-color: rgba(59, 130, 246, 0.3); +} + +:global(.dark-theme) .costVal { + color: #93c5fd; +} + +:global(.dark-theme) .costRowNeut .costVal, +:global(.dark-theme) .costHintWarn { + color: #fbbf24; +} + +/* Navigation */ +.stepNav { + display: flex; + justify-content: space-between; + align-items: center; + margin-top: auto; + padding-top: 0.5rem; + gap: 0.75rem; +} + +.navBack { + padding: 0.5rem 1rem; + background: var(--surface-color); + color: var(--text-secondary, #666); + border: 1px solid var(--border-color, #ddd); + border-radius: 6px; + font-size: 0.875rem; + font-weight: 500; + cursor: pointer; + transition: background 0.2s; +} + +.navBack:hover { + background: var(--bg-secondary, #f5f5f5); +} + +.navNext { + display: flex; + align-items: center; + gap: 0.375rem; + padding: 0.5rem 1.25rem; + background: var(--primary-color, #f25843); + color: white; + border: none; + border-radius: 6px; + font-size: 0.875rem; + font-weight: 500; + cursor: pointer; + transition: background 0.2s; +} + +.navNext:hover { + background: var(--primary-dark, #d94d3a); +} + +.navConnect { + display: flex; + align-items: center; + gap: 0.375rem; + padding: 0.625rem 1.5rem; + background: var(--primary-color, #f25843); + color: white; + border: none; + border-radius: 6px; + font-size: 0.9375rem; + font-weight: 600; + cursor: pointer; + transition: background 0.2s; +} + +.navConnect:hover:not(:disabled) { + background: var(--primary-dark, #d94d3a); +} + +.navConnect:disabled { + opacity: 0.6; + cursor: not-allowed; +} + +/* Dark theme */ +:global(.dark-theme) .connectorCard { + background: var(--surface-color); +} + +:global(.dark-theme) .prefSelect, +:global(.dark-theme) .prefNumber { + background: var(--surface-color); + color: var(--text-primary); +} + +:global(.dark-theme) .summary { + border-color: var(--border-color); +} + +:global(.dark-theme) .summaryRow { + border-color: var(--border-color); +} diff --git a/src/components/AddConnectionWizard/AddConnectionWizard.tsx b/src/components/AddConnectionWizard/AddConnectionWizard.tsx new file mode 100644 index 0000000..ca5dbac --- /dev/null +++ b/src/components/AddConnectionWizard/AddConnectionWizard.tsx @@ -0,0 +1,521 @@ +/** + * AddConnectionWizard + * + * Multi-step modal for adding a new connector with optional knowledge + * ingestion consent and per-connection preferences (§2.6). + * + * Steps: + * 0 — Connector wählen + * 1 — Consent (Wissensdatenbank Ja/Nein) + * 2 — Präferenzen (nur wenn Ja) + * 3 — Zusammenfassung + OAuth starten + */ + +import React, { useState } from 'react'; +import { Modal } from '../UiComponents/Modal/Modal'; +import { FaGoogle, FaMicrosoft, FaTasks, FaDatabase, FaShieldAlt, FaCheck, FaArrowRight, FaInfoCircle } from 'react-icons/fa'; +import type { KnowledgePreferences } from '../../api/connectionApi'; +import styles from './AddConnectionWizard.module.css'; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +export type ConnectorType = 'google' | 'msft' | 'clickup'; + +interface WizardState { + step: 0 | 1 | 2 | 3; + connector: ConnectorType | null; + knowledgeEnabled: boolean; + prefs: KnowledgePreferences; +} + +const DEFAULT_PREFS: KnowledgePreferences = { + schemaVersion: 1, + neutralizeBeforeEmbed: false, + mailContentDepth: 'full', + mailIndexAttachments: false, + filesIndexBinaries: true, + clickupScope: 'title_description', + clickupIndexAttachments: false, + maxAgeDays: 90, +}; + +const CONNECTOR_LABELS: Record = { + google: 'Google', + msft: 'Microsoft 365', + clickup: 'ClickUp', +}; + +const CONNECTOR_ICONS: Record = { + google: , + msft: , + clickup: , +}; + +// --------------------------------------------------------------------------- +// Cost estimate helper +// --------------------------------------------------------------------------- + +/** + * Returns a cost estimate broken into two lines: + * + * 1. Embedding (OpenAI text-embedding-3-small, $0.02 / 1M tokens) — always tiny. + * 2. Neutralization (Private LLM / qwen2.5 on-premise, CHF 0.01 per LLM call) + * — this is the DOMINANT cost when enabled. One call per email/task for + * short content; several calls for long threads or files. + * + * Numbers are conservative ranges. Subsequent syncs are cheaper because + * unchanged content is deduplicated before any LLM/embedding call. + */ +function computeCostEstimate( + connector: ConnectorType | null, + prefs: KnowledgePreferences, +): { + embeddingLow: string; + embeddingHigh: string; + neutralizationLow: string | null; + neutralizationHigh: string | null; + note: string; +} | null { + if (!connector) return null; + + // ---- Embedding (OpenAI, USD) ---- + const EMBED_USD_PER_M = 0.02; + const tokensPerMail: Record = { metadata: 30, snippet: 120, full: 500 }; + const depth = prefs.mailContentDepth ?? 'full'; + const maxAge = prefs.maxAgeDays ?? 90; + const mailCount = Math.min(500, Math.round((maxAge / 90) * 500)); + const taskCount = Math.min(500, Math.round((maxAge / 90) * 300)); + + let embedLowTokens = 0; + let embedHighTokens = 0; + + if (connector === 'google' || connector === 'msft') { + const mailTokens = mailCount * tokensPerMail[depth]; + embedLowTokens += mailTokens * 0.6; + embedHighTokens += mailTokens * 1.5 + 500_000; // Drive/SharePoint + if (prefs.mailIndexAttachments) embedHighTokens += 200_000; + } else if (connector === 'clickup') { + const scope = prefs.clickupScope ?? 'title_description'; + const tpt = scope === 'titles' ? 30 : scope === 'title_description' ? 200 : 400; + embedLowTokens += taskCount * tpt * 0.6; + embedHighTokens += taskCount * tpt * 1.5; + } + + const fmtUsd = (tokens: number) => { + const usd = (tokens / 1_000_000) * EMBED_USD_PER_M; + if (usd < 0.001) return '< 0.01 $'; + if (usd < 0.10) return `~${usd.toFixed(3)} $`; + return `~${usd.toFixed(2)} $`; + }; + + // ---- Neutralization (Private LLM, CHF 0.01/call) ---- + // Each item (email / task / file) = 1 LLM call for short content, + // 2-4 for long threads/documents. + const NEUT_CHF_PER_CALL = 0.01; + let neutLow: string | null = null; + let neutHigh: string | null = null; + + if (prefs.neutralizeBeforeEmbed) { + let lowCalls = 0; + let highCalls = 0; + + if (connector === 'google' || connector === 'msft') { + lowCalls += mailCount * 1; // 1 call / short email + highCalls += mailCount * 3; // up to 3 calls / long thread + lowCalls += 20; // Drive/SharePoint files (low) + highCalls += 200; // Drive/SharePoint files (high, large PDFs) + } else if (connector === 'clickup') { + lowCalls += taskCount * 1; + highCalls += taskCount * 2; + } + + const fmtChf = (calls: number) => { + const chf = calls * NEUT_CHF_PER_CALL; + if (chf < 0.01) return '< 0.01 CHF'; + return `~${chf.toFixed(2)} CHF`; + }; + + neutLow = fmtChf(lowCalls); + neutHigh = fmtChf(highCalls); + } + + return { + embeddingLow: fmtUsd(embedLowTokens), + embeddingHigh: fmtUsd(embedHighTokens), + neutralizationLow: neutLow, + neutralizationHigh: neutHigh, + note: 'Einmalig beim ersten Sync. Folge-Syncs kosten weniger (nur neue Inhalte).', + }; +} + +// --------------------------------------------------------------------------- +// Props +// --------------------------------------------------------------------------- + +interface AddConnectionWizardProps { + open: boolean; + onClose: () => void; + onConnect: ( + type: ConnectorType, + knowledgeEnabled: boolean, + prefs: KnowledgePreferences | null, + ) => Promise; + isConnecting?: boolean; +} + +// --------------------------------------------------------------------------- +// Component +// --------------------------------------------------------------------------- + +export const AddConnectionWizard: React.FC = ({ + open, + onClose, + onConnect, + isConnecting = false, +}) => { + const [state, setState] = useState({ + step: 0, + connector: null, + knowledgeEnabled: false, + prefs: { ...DEFAULT_PREFS }, + }); + + const reset = () => + setState({ step: 0, connector: null, knowledgeEnabled: false, prefs: { ...DEFAULT_PREFS } }); + + const handleClose = () => { + reset(); + onClose(); + }; + + const setStep = (step: WizardState['step']) => setState(s => ({ ...s, step })); + const setConnector = (connector: ConnectorType) => + setState(s => ({ ...s, connector, step: 1 })); + const setKnowledgeEnabled = (v: boolean) => + setState(s => ({ ...s, knowledgeEnabled: v, step: v ? 2 : 3 })); + const updatePref = (key: K, value: KnowledgePreferences[K]) => + setState(s => ({ ...s, prefs: { ...s.prefs, [key]: value } })); + + const handleConnect = async () => { + if (!state.connector) return; + await onConnect( + state.connector, + state.knowledgeEnabled, + state.knowledgeEnabled ? state.prefs : null, + ); + reset(); + onClose(); + }; + + const STEP_LABELS = ['Anbieter', 'Zustimmung', 'Einstellungen', 'Übersicht']; + const visibleSteps = state.knowledgeEnabled + ? [0, 1, 2, 3] + : [0, 1, 3]; + + return ( + + {/* Stepper */} +
+ {[0, 1, 2, 3].map(i => ( +
i ? styles.stepDotDone : '', + !visibleSteps.includes(i) ? styles.stepDotHidden : '', + ].join(' ')} + > + {state.step > i ? : i + 1} +
+ ))} +
+ +
+ {/* ---- Step 0: Connector ---- */} + {state.step === 0 && ( +
+

Anbieter wählen

+

Welchen Dienst möchtest du verbinden?

+
+ {(['google', 'msft', 'clickup'] as ConnectorType[]).map(type => ( + + ))} +
+
+ )} + + {/* ---- Step 1: Consent ---- */} + {state.step === 1 && ( +
+
+

Wissensdatenbank

+

+ Möchtest du Inhalte aus dieser Verbindung in deine persönliche + Wissensdatenbank aufnehmen, damit die KI beim Antworten auf Informationen + aus{' '} + {state.connector ? CONNECTOR_LABELS[state.connector] : 'diesem Dienst'}{' '} + zurückgreifen kann? +

+

+ Du kannst diese Einstellung später in den Verbindungsdetails ändern. +

+
+ + +
+
+ +
+
+ )} + + {/* ---- Step 2: Preferences ---- */} + {state.step === 2 && ( +
+

Einstellungen

+

+ Steuere, welche Inhalte und in welcher Form sie indexiert werden. +

+ +
+ +

+ Persönliche Daten (Namen, E-Mail-Adressen) werden vor dem Speichern ersetzt. +

+
+ + {(state.connector === 'google' || state.connector === 'msft') && ( + <> +
+ +
+
+ +
+ + )} + + {state.connector === 'clickup' && ( +
+ +
+ )} + +
+ +

0 = kein Limit

+
+ +
+ + +
+
+ )} + + {/* ---- Step 3: Summary ---- */} + {state.step === 3 && ( +
+

Zusammenfassung

+
+
+ Anbieter + + {CONNECTOR_ICONS[state.connector!]}  + {state.connector ? CONNECTOR_LABELS[state.connector] : '—'} + +
+
+ Wissensdatenbank + + {state.knowledgeEnabled ? '✓ Aktiv' : '✗ Nicht aktiv'} + +
+ {state.knowledgeEnabled && ( + <> +
+ Anonymisierung + + {state.prefs.neutralizeBeforeEmbed ? 'Ja' : 'Nein'} + +
+ {(state.connector === 'google' || state.connector === 'msft') && ( +
+ E-Mail-Tiefe + + {{ metadata: 'Nur Metadaten', snippet: 'Vorschautext', full: 'Volltext' }[ + state.prefs.mailContentDepth ?? 'full' + ] ?? state.prefs.mailContentDepth} + +
+ )} + {state.connector === 'clickup' && ( +
+ Aufgaben-Inhalt + + {{ + titles: 'Nur Titel', + title_description: 'Titel + Beschreibung', + with_comments: 'Titel + Beschreibung + Kommentare', + }[state.prefs.clickupScope ?? 'title_description'] ?? state.prefs.clickupScope} + +
+ )} +
+ Zeitfenster + + {state.prefs.maxAgeDays ? `${state.prefs.maxAgeDays} Tage` : 'Unbegrenzt'} + +
+ + )} +
+ + {/* Cost estimate — only shown when knowledge ingestion is enabled */} + {state.knowledgeEnabled && (() => { + const est = computeCostEstimate(state.connector, state.prefs); + if (!est) return null; + return ( +
+ +
+ Geschätzte Kosten (erster Sync) + + + + + + + {est.neutralizationLow && ( + + + + + )} + +
Embedding + {est.embeddingLow} – {est.embeddingHigh} +
Anonymisierung (Private LLM) + {est.neutralizationLow} – {est.neutralizationHigh} +
+ {est.neutralizationLow && ( + + ⚠ Anonymisierung ist der Hauptkostentreiber (CHF 0.01 pro LLM-Aufruf, on-premise). + + )} + {est.note} +
+
+ ); + })()} + +
+ + +
+
+ )} +
+
+ ); +}; + +export default AddConnectionWizard; diff --git a/src/hooks/useConnections.ts b/src/hooks/useConnections.ts index ad1f308..670bca7 100644 --- a/src/hooks/useConnections.ts +++ b/src/hooks/useConnections.ts @@ -708,6 +708,90 @@ export function useConnections() { } }, [connections, request]); + /** + * Generic wizard entry-point: create a connection of any supported type with + * optional knowledge consent + preferences, then immediately open the OAuth + * popup. The three individual `create*ConnectionAndAuth` methods are preserved + * for backward-compat but new wizard code should call this. + */ + const createConnectionAndAuth = async ( + type: 'google' | 'msft' | 'clickup', + knowledgeIngestionEnabled: boolean, + knowledgePreferences?: import('../api/connectionApi').KnowledgePreferences | null, + ): Promise => { + if (isConnecting) return; + setIsConnecting(true); + try { + const newConnection = await createConnection({ + type, + authority: type, + knowledgeIngestionEnabled, + knowledgePreferences: knowledgePreferences ?? null, + }); + + const connectResponse = await connectServiceApi(request, newConnection.id); + if (!connectResponse.authUrl) { + throw new Error('No OAuth URL received from backend'); + } + + const apiBaseUrl = getApiBaseUrl(); + let authUrl = connectResponse.authUrl; + if (authUrl.startsWith('/')) authUrl = `${apiBaseUrl}${authUrl}`; + + return await new Promise((resolve, reject) => { + const popup = window.open(authUrl, `${type}-wizard`, 'width=500,height=600,scrollbars=yes,resizable=yes'); + if (!popup) { + setIsConnecting(false); + reject(new Error('Popup was blocked. Please allow popups and try again.')); + return; + } + + const SUCCESS_TYPES = new Set([ + 'google_connection_success', 'msft_connection_success', 'clickup_connection_success', + 'google_auth_success', + ]); + const ERROR_TYPES = new Set([ + 'google_connection_error', 'msft_connection_error', 'clickup_connection_error', + ]); + + const checkClosed = setInterval(() => { + if (popup.closed) { + clearInterval(checkClosed); + window.removeEventListener('message', messageListener); + setIsConnecting(false); + fetchConnections(); + resolve(); + } + }, 1000); + + const messageListener = (event: MessageEvent) => { + const apiUrl = new URL(apiBaseUrl); + if (event.origin !== apiUrl.origin) return; + + if (SUCCESS_TYPES.has(event.data.type)) { + clearInterval(checkClosed); + window.removeEventListener('message', messageListener); + popup.close(); + setIsConnecting(false); + fetchConnections(); + resolve(); + } else if (ERROR_TYPES.has(event.data.type)) { + clearInterval(checkClosed); + window.removeEventListener('message', messageListener); + popup.close(); + setIsConnecting(false); + reject(new Error(event.data.error || `${type} connection failed`)); + } + }; + + window.addEventListener('message', messageListener); + }); + } catch (error: any) { + setIsConnecting(false); + throw error; + } + }; + return { connections, data: connections, // Alias for FormGenerator compatibility @@ -726,6 +810,7 @@ export function useConnections() { createClickupConnectionAndAuth, createInfomaniakConnection, submitInfomaniakToken, + createConnectionAndAuth, isLoading, loading: isLoading, // Alias for FormGenerator compatibility isConnecting, diff --git a/src/pages/basedata/ConnectionsPage.module.css b/src/pages/basedata/ConnectionsPage.module.css new file mode 100644 index 0000000..0d94c59 --- /dev/null +++ b/src/pages/basedata/ConnectionsPage.module.css @@ -0,0 +1,90 @@ +/* ConnectionsPage — supplemental styles for sync banner */ + +.syncBanner { + display: flex; + align-items: flex-start; + gap: 0.75rem; + padding: 0.875rem 1rem 0.875rem 1.125rem; + margin: 0 0 1rem; + background: linear-gradient(135deg, #fffbeb, #fef3c7); + border: 1px solid #fcd34d; + border-radius: 8px; + box-shadow: 0 1px 4px rgba(0, 0, 0, 0.06); + animation: slidein 0.25s ease; +} + +@keyframes slidein { + from { opacity: 0; transform: translateY(-6px); } + to { opacity: 1; transform: translateY(0); } +} + +.syncSpinner { + flex-shrink: 0; + margin-top: 3px; + color: #d97706; + font-size: 1rem; + animation: spin 1.4s linear infinite; +} + +@keyframes spin { + to { transform: rotate(360deg); } +} + +.syncText { + flex: 1; + display: flex; + flex-direction: column; + gap: 0.2rem; +} + +.syncTitle { + font-weight: 600; + font-size: 0.9375rem; + color: #92400e; +} + +.syncDetail { + font-size: 0.8125rem; + color: #78350f; + line-height: 1.5; +} + +.syncDismiss { + flex-shrink: 0; + background: none; + border: none; + cursor: pointer; + color: #b45309; + padding: 2px 4px; + border-radius: 4px; + font-size: 0.875rem; + display: flex; + align-items: center; + transition: background 0.15s; +} + +.syncDismiss:hover { + background: rgba(0, 0, 0, 0.06); +} + +/* Dark theme */ +:global(.dark-theme) .syncBanner { + background: rgba(251, 191, 36, 0.08); + border-color: rgba(251, 191, 36, 0.3); +} + +:global(.dark-theme) .syncTitle { + color: #fcd34d; +} + +:global(.dark-theme) .syncDetail { + color: #fde68a; +} + +:global(.dark-theme) .syncDismiss { + color: #fbbf24; +} + +:global(.dark-theme) .syncSpinner { + color: #fbbf24; +} diff --git a/src/pages/basedata/ConnectionsPage.tsx b/src/pages/basedata/ConnectionsPage.tsx index 24d4fa1..a1ad7ba 100644 --- a/src/pages/basedata/ConnectionsPage.tsx +++ b/src/pages/basedata/ConnectionsPage.tsx @@ -5,17 +5,22 @@ * Follows the pattern established in AdminUsersPage/WorkflowsPage. */ -import React, { useState, useMemo, useEffect } from 'react'; +import React, { useState, useMemo, useEffect, useRef } from 'react'; import { useConnections, type Connection } from '../../hooks/useConnections'; import { FormGeneratorTable } from '../../components/FormGenerator/FormGeneratorTable'; import { FormGeneratorForm } from '../../components/FormGenerator/FormGeneratorForm'; -import { FaSync, FaGoogle, FaMicrosoft, FaLink, FaRedo, FaShieldAlt, FaTasks, FaCloud, FaSyncAlt } from 'react-icons/fa'; +import { FaSync, FaLink, FaRedo, FaShieldAlt, FaPlus, FaSpinner, FaTimes, FaSyncAlt, FaCloud } from 'react-icons/fa'; import { getApiBaseUrl } from '../../../config/config'; import styles from '../admin/Admin.module.css'; - +import bannerStyles from './ConnectionsPage.module.css'; +import { AddConnectionWizard } from '../../components/AddConnectionWizard/AddConnectionWizard'; +import type { ConnectorType } from '../../components/AddConnectionWizard/AddConnectionWizard'; +import type { KnowledgePreferences } from '../../api/connectionApi'; import { useLanguage } from '../../providers/language/LanguageContext'; import { resolveColumnTypes } from '../../utils/columnTypeResolver'; +const SYNC_BANNER_TTL_MS = 10 * 60 * 1000; // 10 minutes — conservative upper bound for bootstrap + export const ConnectionsPage: React.FC = () => { const { t } = useLanguage(); @@ -32,9 +37,7 @@ export const ConnectionsPage: React.FC = () => { updateOptimistically, deleteConnection, handleInlineUpdate, - createGoogleConnectionAndAuth, - createMicrosoftConnectionAndAuth, - createClickupConnectionAndAuth, + createConnectionAndAuth, createInfomaniakConnection, submitInfomaniakToken, connectWithPopup, @@ -48,6 +51,23 @@ export const ConnectionsPage: React.FC = () => { const [refreshingConnections, setRefreshingConnections] = useState>(new Set()); const [reconnectingConnections, setReconnectingConnections] = useState>(new Set()); const [adminConsentPending, setAdminConsentPending] = useState(false); + const [wizardOpen, setWizardOpen] = useState(false); + // Banner shown while knowledge bootstrap is running in the background + const [syncBanner, setSyncBanner] = useState<{ + connector: string; + startedAt: number; + } | null>(null); + const syncBannerTimer = useRef | null>(null); + + const showSyncBanner = (connector: string) => { + if (syncBannerTimer.current) clearTimeout(syncBannerTimer.current); + setSyncBanner({ connector, startedAt: Date.now() }); + syncBannerTimer.current = setTimeout(() => setSyncBanner(null), SYNC_BANNER_TTL_MS); + }; + const dismissSyncBanner = () => { + if (syncBannerTimer.current) clearTimeout(syncBannerTimer.current); + setSyncBanner(null); + }; // Infomaniak PAT modal: holds the pending connectionId (created up-front so the // user only commits if they actually paste a valid token; on cancel we delete it). @@ -201,35 +221,20 @@ export const ConnectionsPage: React.FC = () => { } }; - // Guards prevent double-trigger while the OAuth popup is open, which would - // otherwise create additional orphan PENDING connections on every click. - const handleCreateGoogle = async () => { - if (isConnecting) return; + const handleWizardConnect = async ( + type: ConnectorType, + knowledgeEnabled: boolean, + knowledgePreferences: KnowledgePreferences | null, + ) => { try { - await createGoogleConnectionAndAuth(); + await createConnectionAndAuth(type, knowledgeEnabled, knowledgePreferences); refetch(); + if (knowledgeEnabled) { + const LABELS: Record = { google: 'Google', msft: 'Microsoft 365', clickup: 'ClickUp' }; + showSyncBanner(LABELS[type] ?? type); + } } catch (error) { - console.error('Error creating Google connection:', error); - } - }; - - const handleCreateMicrosoft = async () => { - if (isConnecting) return; - try { - await createMicrosoftConnectionAndAuth(); - refetch(); - } catch (error) { - console.error('Error creating Microsoft connection:', error); - } - }; - - const handleCreateClickup = async () => { - if (isConnecting) return; - try { - await createClickupConnectionAndAuth(); - refetch(); - } catch (error) { - console.error('Error creating ClickUp connection:', error); + console.error('Error creating connection via wizard:', error); } }; @@ -356,28 +361,13 @@ export const ConnectionsPage: React.FC = () => { {canCreate && ( <> - - + + )} +
{
)} + + setWizardOpen(false)} + onConnect={handleWizardConnect} + isConnecting={isConnecting} + /> ); }; From c8e9304801f1463d5fc98ddfe67b2e3bc8f74dfb Mon Sep 17 00:00:00 2001 From: Ida Date: Wed, 29 Apr 2026 18:25:42 +0200 Subject: [PATCH 2/6] gruppierung fertig gestellt formgenerator --- src/api/connectionApi.ts | 17 + src/api/fileApi.ts | 4 + src/api/mandateApi.ts | 4 + src/api/promptApi.ts | 4 + src/api/userApi.ts | 4 + .../FormGeneratorControls.tsx | 19 +- .../FormGeneratorTable.module.css | 1 + .../FormGeneratorTable/FormGeneratorTable.tsx | 882 +++++++++++++++--- .../GroupingManager/GroupRow.module.css | 328 +++++++ .../GroupingManager/GroupRow.tsx | 293 ++++++ src/hooks/useConnections.ts | 8 +- src/pages/basedata/ConnectionsPage.tsx | 3 + 12 files changed, 1439 insertions(+), 128 deletions(-) create mode 100644 src/components/FormGenerator/GroupingManager/GroupRow.module.css create mode 100644 src/components/FormGenerator/GroupingManager/GroupRow.tsx diff --git a/src/api/connectionApi.ts b/src/api/connectionApi.ts index 67d6750..41a79e4 100644 --- a/src/api/connectionApi.ts +++ b/src/api/connectionApi.ts @@ -55,6 +55,19 @@ export interface PaginationParams { sort?: Array<{ field: string; direction: 'asc' | 'desc' }>; filters?: Record; search?: string; + /** Scope request to items of this group (resolved server-side to itemIds IN-filter). */ + groupId?: string; + /** If set, persist this group tree on the backend before fetching (optimistic save). */ + saveGroupTree?: TableGroupNode[]; +} + +export interface TableGroupNode { + id: string; + name: string; + itemIds: string[]; + subGroups: TableGroupNode[]; + order: number; + isExpanded: boolean; } export interface PaginatedResponse { @@ -65,6 +78,8 @@ export interface PaginatedResponse { totalItems: number; totalPages: number; }; + /** Current group tree for this (user, contextKey) pair — undefined if no grouping configured. */ + groupTree?: TableGroupNode[]; } export interface CreateConnectionData { @@ -123,6 +138,8 @@ export async function fetchConnections( if (params.sort) paginationObj.sort = params.sort; if (params.filters) paginationObj.filters = params.filters; if (params.search) paginationObj.search = params.search; + if (params.groupId) paginationObj.groupId = params.groupId; + if (params.saveGroupTree !== undefined) paginationObj.saveGroupTree = params.saveGroupTree; if (Object.keys(paginationObj).length > 0) { requestParams.pagination = JSON.stringify(paginationObj); diff --git a/src/api/fileApi.ts b/src/api/fileApi.ts index 18dc47e..44102f1 100644 --- a/src/api/fileApi.ts +++ b/src/api/fileApi.ts @@ -34,6 +34,8 @@ export interface PaginationParams { sort?: Array<{ field: string; direction: 'asc' | 'desc' }>; filters?: Record; search?: string; + groupId?: string; + saveGroupTree?: any[]; } export interface PaginatedResponse { @@ -103,6 +105,8 @@ export async function fetchFiles( if (params.sort) paginationObj.sort = params.sort; if (params.filters) paginationObj.filters = params.filters; if (params.search) paginationObj.search = params.search; + if (params.groupId) paginationObj.groupId = params.groupId; + if (params.saveGroupTree !== undefined) paginationObj.saveGroupTree = params.saveGroupTree; if (Object.keys(paginationObj).length > 0) { requestParams.pagination = JSON.stringify(paginationObj); diff --git a/src/api/mandateApi.ts b/src/api/mandateApi.ts index 38bf41c..7946395 100644 --- a/src/api/mandateApi.ts +++ b/src/api/mandateApi.ts @@ -46,6 +46,8 @@ export interface PaginationParams { sort?: Array<{ field: string; direction: 'asc' | 'desc' }>; filters?: Record; search?: string; + groupId?: string; + saveGroupTree?: any[]; } export interface PaginatedResponse { @@ -84,6 +86,8 @@ export async function fetchMandates( if (params.sort) paginationObj.sort = params.sort; if (params.filters) paginationObj.filters = params.filters; if (params.search) paginationObj.search = params.search; + if (params.groupId) paginationObj.groupId = params.groupId; + if (params.saveGroupTree !== undefined) paginationObj.saveGroupTree = params.saveGroupTree; if (Object.keys(paginationObj).length > 0) { requestParams.pagination = JSON.stringify(paginationObj); diff --git a/src/api/promptApi.ts b/src/api/promptApi.ts index 00f1be7..e735ae0 100644 --- a/src/api/promptApi.ts +++ b/src/api/promptApi.ts @@ -49,6 +49,8 @@ export interface PaginationParams { sort?: Array<{ field: string; direction: 'asc' | 'desc' }>; filters?: Record; search?: string; + groupId?: string; + saveGroupTree?: any[]; } export interface PaginatedResponse { @@ -110,6 +112,8 @@ export async function fetchPrompts( if (params.sort) paginationObj.sort = params.sort; if (params.filters) paginationObj.filters = params.filters; if (params.search) paginationObj.search = params.search; + if (params.groupId) paginationObj.groupId = params.groupId; + if (params.saveGroupTree !== undefined) paginationObj.saveGroupTree = params.saveGroupTree; if (Object.keys(paginationObj).length > 0) { requestParams.pagination = JSON.stringify(paginationObj); diff --git a/src/api/userApi.ts b/src/api/userApi.ts index d16bf38..98dd7a2 100644 --- a/src/api/userApi.ts +++ b/src/api/userApi.ts @@ -48,6 +48,8 @@ export interface PaginationParams { sort?: Array<{ field: string; direction: 'asc' | 'desc' }>; filters?: Record; search?: string; + groupId?: string; + saveGroupTree?: any[]; } export interface PaginatedResponse { @@ -152,6 +154,8 @@ export async function fetchUsers( if (params.sort) paginationObj.sort = params.sort; if (params.filters) paginationObj.filters = params.filters; if (params.search) paginationObj.search = params.search; + if (params.groupId) paginationObj.groupId = params.groupId; + if (params.saveGroupTree !== undefined) paginationObj.saveGroupTree = params.saveGroupTree; if (Object.keys(paginationObj).length > 0) { requestParams.pagination = JSON.stringify(paginationObj); diff --git a/src/components/FormGenerator/FormGeneratorControls/FormGeneratorControls.tsx b/src/components/FormGenerator/FormGeneratorControls/FormGeneratorControls.tsx index 0e5ff03..8b43b01 100644 --- a/src/components/FormGenerator/FormGeneratorControls/FormGeneratorControls.tsx +++ b/src/components/FormGenerator/FormGeneratorControls/FormGeneratorControls.tsx @@ -4,7 +4,7 @@ import { useLanguage } from '../../../providers/language/LanguageContext'; import styles from './FormGeneratorControls.module.css'; import { Button } from '../../UiComponents/Button'; import { IoIosRefresh } from "react-icons/io"; -import { FaTrash, FaDownload } from "react-icons/fa"; +import { FaTrash, FaDownload, FaLayerGroup } from "react-icons/fa"; import type { AttributeType } from '../../../utils/attributeTypeMapper'; // Generic field/column config interface @@ -77,6 +77,10 @@ export interface FormGeneratorControlsProps { onSelectAllFiltered?: () => void; selectAllFilteredActive?: boolean; selectAllFilteredLoading?: boolean; + // Grouping + groupingEnabled?: boolean; + onCreateGroup?: () => void; + activeGroupId?: string | null; } export function FormGeneratorControls({ @@ -110,6 +114,9 @@ export function FormGeneratorControls({ onSelectAllFiltered, selectAllFilteredActive = false, selectAllFilteredLoading = false, + groupingEnabled = false, + onCreateGroup, + activeGroupId, }: FormGeneratorControlsProps) { const { t } = useLanguage(); @@ -212,6 +219,16 @@ export function FormGeneratorControls({ {csvExporting ? t('Exportiere...') : 'CSV'} )} + {groupingEnabled && onCreateGroup && ( + + )} {onRefresh && ( + + {/* Folder icon */} + + {isExpanded ? : } + + + {/* Name / inline input */} + {isEditing ? ( + { + if (e.key === 'Enter') onEditCommit(e.currentTarget.value); + if (e.key === 'Escape') onEditCancel(); + }} + onBlur={(e) => onEditCommit(e.target.value)} + /> + ) : ( + { e.stopPropagation(); onToggle(); }}> + {node.name || {t('(Unbenannt)')}} + + )} + + {/* Item count badge */} + {!isEditing && ( + + {visibleCount < totalCount && totalCount > 0 + ? `${visibleCount} / ${totalCount}` + : String(totalCount)} + + )} + + {/* Drop hint */} + {(isDragOver || isDragOverFromGroup) && ( + + {isDragOverFromGroup ? t('Als Untergruppe ablegen') : t('Hierher ziehen')} + + )} + + {/* ── Bulk actions (delete all, custom batch) right after badge ── */} + {!isEditing && bulkActions.length > 0 && ( + <> + + + {bulkActions.map((action, i) => ( + + ))} + + + )} + + {/* ── Group management: rename / add-subgroup ── */} + {!isEditing && ( + + + + + )} + + + + + + + ); +} + +// --------------------------------------------------------------------------- +// BreadcrumbRow +// --------------------------------------------------------------------------- + +interface BreadcrumbRowProps { + groupName: string; + totalItems: number; + colSpan: number; + onBack: () => void; +} + +export function BreadcrumbRow({ groupName, totalItems, colSpan, onBack }: BreadcrumbRowProps) { + const { t } = useLanguage(); + return ( + + +
+ + + {groupName} + {totalItems > 0 && ( + + ({totalItems} {t('Einträge')}) + + )} +
+ + + ); +} + +// --------------------------------------------------------------------------- +// UngroupedRow — also a drop zone for removing items/groups from groups +// --------------------------------------------------------------------------- + +interface UngroupedRowProps { + count: number; + colSpan: number; + isDragOver?: boolean; + onDragOver?: (e: React.DragEvent) => void; + onDrop?: (e: React.DragEvent) => void; + onDragLeave?: () => void; +} + +export function UngroupedRow({ count, colSpan, isDragOver, onDragOver, onDrop, onDragLeave }: UngroupedRowProps) { + const { t } = useLanguage(); + return ( + e.preventDefault()} + > + + + {t('Nicht zugeordnet')} + {count} + {isDragOver && {t('Aus Gruppe entfernen')}} + + + ); +} diff --git a/src/hooks/useConnections.ts b/src/hooks/useConnections.ts index 670bca7..b3b43d7 100644 --- a/src/hooks/useConnections.ts +++ b/src/hooks/useConnections.ts @@ -22,6 +22,7 @@ import { // Re-export types for backward compatibility export type { Connection, AttributeDefinition, PaginationParams, CreateConnectionData, ConnectResponse }; +export type { TableGroupNode } from '../api/connectionApi'; // Hook for managing connections export function useConnections() { @@ -34,6 +35,7 @@ export function useConnections() { totalItems: number; totalPages: number; } | null>(null); + const [groupTree, setGroupTree] = useState([]); const [isConnecting, setIsConnecting] = useState(false); const [connectError, setConnectError] = useState(null); const { request, isLoading, error } = useApiRequest(); @@ -101,6 +103,9 @@ export function useConnections() { if (data.pagination) { setPagination(data.pagination); } + if (Array.isArray(data.groupTree)) { + setGroupTree(data.groupTree); + } } else { // Handle non-paginated response (backward compatibility) const items = Array.isArray(data) ? data : []; @@ -826,7 +831,8 @@ export function useConnections() { // Additional methods for FormGenerator updateOptimistically, handleInlineUpdate, - fetchConnectionById + fetchConnectionById, + groupTree, }; } diff --git a/src/pages/basedata/ConnectionsPage.tsx b/src/pages/basedata/ConnectionsPage.tsx index a1ad7ba..39673ca 100644 --- a/src/pages/basedata/ConnectionsPage.tsx +++ b/src/pages/basedata/ConnectionsPage.tsx @@ -44,6 +44,7 @@ export const ConnectionsPage: React.FC = () => { refreshMicrosoftToken, refreshGoogleToken, isConnecting, + groupTree, } = useConnections(); const [editingConnection, setEditingConnection] = useState(null); @@ -469,7 +470,9 @@ export const ConnectionsPage: React.FC = () => { handleDelete: deleteConnection, handleInlineUpdate, updateOptimistically, + groupTree, }} + groupingConfig={{ contextKey: 'connections', enabled: true }} emptyMessage={t('Keine Verbindungen gefunden')} /> From 31586d62c1001829990b0d9b6e9faf9717163213 Mon Sep 17 00:00:00 2001 From: Ida Date: Wed, 29 Apr 2026 18:32:50 +0200 Subject: [PATCH 3/6] build errors --- .../AddConnectionWizard.tsx | 2 +- .../FormGeneratorTable/FormGeneratorTable.tsx | 34 ++++--------------- .../GroupingManager/GroupRow.tsx | 2 +- 3 files changed, 8 insertions(+), 30 deletions(-) diff --git a/src/components/AddConnectionWizard/AddConnectionWizard.tsx b/src/components/AddConnectionWizard/AddConnectionWizard.tsx index ca5dbac..10fe952 100644 --- a/src/components/AddConnectionWizard/AddConnectionWizard.tsx +++ b/src/components/AddConnectionWizard/AddConnectionWizard.tsx @@ -209,7 +209,7 @@ export const AddConnectionWizard: React.FC = ({ onClose(); }; - const STEP_LABELS = ['Anbieter', 'Zustimmung', 'Einstellungen', 'Übersicht']; + const STEP_LABELS = ['Anbieter', 'Zustimmung', 'Einstellungen', 'Übersicht'] as const; const visibleSteps = state.knowledgeEnabled ? [0, 1, 2, 3] : [0, 1, 3]; diff --git a/src/components/FormGenerator/FormGeneratorTable/FormGeneratorTable.tsx b/src/components/FormGenerator/FormGeneratorTable/FormGeneratorTable.tsx index b63d245..20f9789 100644 --- a/src/components/FormGenerator/FormGeneratorTable/FormGeneratorTable.tsx +++ b/src/components/FormGenerator/FormGeneratorTable/FormGeneratorTable.tsx @@ -154,28 +154,6 @@ function _treeMoveItemsToGroup(tree: TableGroupNode[], itemIds: string[], groupI if (groupId) for (const id of itemIds) t = _treeAddItemToGroup(t, groupId, id); return t; } -function _treeReorder(tree: TableGroupNode[], groupId: string, dir: 'up' | 'down'): TableGroupNode[] { - const _at = (nodes: TableGroupNode[]): TableGroupNode[] => { - const idx = nodes.findIndex(n => n.id === groupId); - if (idx === -1) return nodes.map(n => ({ ...n, subGroups: _at(n.subGroups) })); - const arr = [...nodes]; - if (dir === 'up' && idx > 0) [arr[idx - 1], arr[idx]] = [arr[idx], arr[idx - 1]]; - if (dir === 'down' && idx < arr.length - 1) [arr[idx], arr[idx + 1]] = [arr[idx + 1], arr[idx]]; - return arr; - }; - return _at(tree); -} -function _treeFlatten(tree: TableGroupNode[]): Array<{ node: TableGroupNode; depth: number; parentId: string | null }> { - const result: Array<{ node: TableGroupNode; depth: number; parentId: string | null }> = []; - const _walk = (nodes: TableGroupNode[], depth: number, parentId: string | null) => { - nodes.forEach(n => { - result.push({ node: n, depth, parentId }); - _walk(n.subGroups, depth + 1, n.id); - }); - }; - _walk(tree, 0, null); - return result; -} /** Returns the id of the group that directly contains `itemId`, or null. */ function _treeGetItemDirectGroupId(tree: TableGroupNode[], itemId: string): string | null { for (const n of tree) { @@ -891,12 +869,12 @@ export function FormGeneratorTable>({ }, [hookData, activeGroupId]); /** Enter a group scope — refetch with groupId filter. */ - const _enterGroup = useCallback((groupId: string) => { - setActiveGroupId(groupId); + const _enterGroup = (_groupId: string) => { + setActiveGroupId(_groupId); if (!hookData?.refetch) return; const s = tableStateRef.current; - hookData.refetch({ page: 1, pageSize: s.pageSize, groupId }); - }, [hookData]); + hookData.refetch({ page: 1, pageSize: s.pageSize, groupId: _groupId }); + }; /** Exit group scope — refetch without groupId. */ const _exitGroup = useCallback(() => { @@ -1024,7 +1002,7 @@ export function FormGeneratorTable>({ const parentGroup = _treeGetGroupParentId(tree, curGroup); actions.moveItemsToGroup([rowId], parentGroup); // Keep the source group collapsed after the move - setExpandedGroupsRef.current(prev => { + setExpandedGroupsRef.current?.(prev => { const next = new Set(prev); next.add(`collapsed-${curGroup}`); return next; @@ -1039,7 +1017,7 @@ export function FormGeneratorTable>({ actions.moveGroupToParent(gId, grandParentId); // Keep the parent group collapsed after the move if (parentId) { - setExpandedGroupsRef.current(prev => { + setExpandedGroupsRef.current?.(prev => { const next = new Set(prev); next.add(`collapsed-${parentId}`); return next; diff --git a/src/components/FormGenerator/GroupingManager/GroupRow.tsx b/src/components/FormGenerator/GroupingManager/GroupRow.tsx index ca13e4f..fb97bef 100644 --- a/src/components/FormGenerator/GroupingManager/GroupRow.tsx +++ b/src/components/FormGenerator/GroupingManager/GroupRow.tsx @@ -85,7 +85,7 @@ export function GroupFolderRow({ onGroupDragLeave, }: GroupFolderRowProps) { const { t } = useLanguage(); - const { confirm, ConfirmDialog } = useConfirm(); + const { ConfirmDialog } = useConfirm(); const inputRef = useRef(null); const totalCount = node.itemIds.length; From aff9dcb7bd55a2cf15134f8a4f162f1d40c04a7b Mon Sep 17 00:00:00 2001 From: Ida Date: Wed, 29 Apr 2026 18:35:42 +0200 Subject: [PATCH 4/6] build errors --- .../AddConnectionWizard/AddConnectionWizard.tsx | 1 - .../FormGeneratorTable/FormGeneratorTable.tsx | 8 -------- 2 files changed, 9 deletions(-) diff --git a/src/components/AddConnectionWizard/AddConnectionWizard.tsx b/src/components/AddConnectionWizard/AddConnectionWizard.tsx index 10fe952..85c9336 100644 --- a/src/components/AddConnectionWizard/AddConnectionWizard.tsx +++ b/src/components/AddConnectionWizard/AddConnectionWizard.tsx @@ -209,7 +209,6 @@ export const AddConnectionWizard: React.FC = ({ onClose(); }; - const STEP_LABELS = ['Anbieter', 'Zustimmung', 'Einstellungen', 'Übersicht'] as const; const visibleSteps = state.knowledgeEnabled ? [0, 1, 2, 3] : [0, 1, 3]; diff --git a/src/components/FormGenerator/FormGeneratorTable/FormGeneratorTable.tsx b/src/components/FormGenerator/FormGeneratorTable/FormGeneratorTable.tsx index 20f9789..3a8b6d0 100644 --- a/src/components/FormGenerator/FormGeneratorTable/FormGeneratorTable.tsx +++ b/src/components/FormGenerator/FormGeneratorTable/FormGeneratorTable.tsx @@ -868,14 +868,6 @@ export function FormGeneratorTable>({ }, 500); }, [hookData, activeGroupId]); - /** Enter a group scope — refetch with groupId filter. */ - const _enterGroup = (_groupId: string) => { - setActiveGroupId(_groupId); - if (!hookData?.refetch) return; - const s = tableStateRef.current; - hookData.refetch({ page: 1, pageSize: s.pageSize, groupId: _groupId }); - }; - /** Exit group scope — refetch without groupId. */ const _exitGroup = useCallback(() => { setActiveGroupId(null); From e7a79a3484b349cb331784470d9b2cbf0218bee0 Mon Sep 17 00:00:00 2001 From: Ida Date: Thu, 30 Apr 2026 10:46:44 +0200 Subject: [PATCH 5/6] UI Verbesserungen Gruppierung und Anwendung auf alle Seiten --- .../FormGeneratorTable.module.css | 50 +++++++++ .../FormGeneratorTable/FormGeneratorTable.tsx | 83 +++++++++++--- .../GroupingManager/GroupRow.module.css | 11 ++ .../GroupingManager/GroupRow.tsx | 101 ++++++++++++++++-- src/hooks/useFiles.ts | 6 ++ src/hooks/usePrompts.ts | 8 +- src/pages/basedata/FilesPage.tsx | 3 + src/pages/basedata/PromptsPage.tsx | 3 + 8 files changed, 241 insertions(+), 24 deletions(-) diff --git a/src/components/FormGenerator/FormGeneratorTable/FormGeneratorTable.module.css b/src/components/FormGenerator/FormGeneratorTable/FormGeneratorTable.module.css index 165ac34..a9d3b5c 100644 --- a/src/components/FormGenerator/FormGeneratorTable/FormGeneratorTable.module.css +++ b/src/components/FormGenerator/FormGeneratorTable/FormGeneratorTable.module.css @@ -486,6 +486,56 @@ cursor: pointer; } +/* Items that live inside a group — subtle tint + left connector */ +.tr.groupedItem { + border-left: 3px solid color-mix(in srgb, var(--color-primary, #4a6fa5) 35%, transparent); +} + +.tr.groupedItem:hover { + background: color-mix(in srgb, var(--color-primary, #4a6fa5) 8%, var(--color-bg, #fff)); +} + +/** + * Hierarchy: set `--row-tree-indent` on the (px). Same row shifts checkbox, actions, and every `.td`. + * Folder rows attach this class from GroupRow.tsx; omit padding on `.folderCell` (inner strip uses `--group-indent`). + */ +.treeRowIndented { + --row-tree-indent: 0px; +} + +.treeRowIndented > .selectColumn { + box-sizing: border-box !important; + padding-top: 4px !important; + padding-right: 4px !important; + padding-bottom: 4px !important; + padding-left: calc(4px + var(--row-tree-indent)) !important; +} + +.treeRowIndented > .actionsColumn { + box-sizing: border-box !important; + padding-top: 4px !important; + padding-right: 4px !important; + padding-bottom: 4px !important; + padding-left: calc(4px + var(--row-tree-indent)) !important; +} + +.treeRowIndented > .td { + box-sizing: border-box !important; + padding-top: 8px !important; + padding-right: 12px !important; + padding-bottom: 8px !important; + padding-left: calc(12px + var(--row-tree-indent)) !important; +} + +.treeRowIndented > .folderCell:first-child { + box-sizing: border-box !important; + padding-left: calc(12px + var(--row-tree-indent)) !important; +} + +.treeRowIndented > .selectColumn + .folderCell { + padding: 0 !important; +} + /* Selection Column */ .selectColumn { text-align: center; diff --git a/src/components/FormGenerator/FormGeneratorTable/FormGeneratorTable.tsx b/src/components/FormGenerator/FormGeneratorTable/FormGeneratorTable.tsx index 3a8b6d0..f6b603e 100644 --- a/src/components/FormGenerator/FormGeneratorTable/FormGeneratorTable.tsx +++ b/src/components/FormGenerator/FormGeneratorTable/FormGeneratorTable.tsx @@ -76,11 +76,15 @@ import { } from '../../../utils/attributeTypeMapper'; import type { AttributeType } from '../../../utils/attributeTypeMapper'; import { FaFilter, FaTrash } from 'react-icons/fa'; -import type { GroupBulkAction } from '../GroupingManager/GroupRow'; import api from '../../../api'; import { PeriodPicker } from '../../PeriodPicker'; import type { PeriodValue } from '../../PeriodPicker'; -import { GroupFolderRow, BreadcrumbRow } from '../GroupingManager/GroupRow'; +import { + GroupFolderRow, + BreadcrumbRow, + GROUP_TREE_INDENT_STEP_PX, + type GroupBulkAction, +} from '../GroupingManager/GroupRow'; /** A filter value can be a plain string, null (for empty/missing), or a * {value, label} object returned by FK-aware filter-values endpoints. */ @@ -3058,17 +3062,23 @@ export function FormGeneratorTable>({ const rowId = _getRowId(row); const isDragging = draggedRowId === rowId; const willUngroup = isDragging && dragWillUngroup; - const indentStyle: React.CSSProperties = indentLevel > 0 - ? { borderLeft: `3px solid color-mix(in srgb, var(--color-primary, #4a6fa5) ${Math.min(indentLevel * 25, 60)}%, transparent)` } + const isGrouped = indentLevel > 0; + const rowBgStyle: React.CSSProperties = isGrouped + ? { background: 'color-mix(in srgb, var(--color-primary, #4a6fa5) 4%, transparent)' } : {}; const ungroupStyle: React.CSSProperties = willUngroup - ? { borderLeft: '3px solid #d69e2e', opacity: 0.5 } + ? { outline: '2px solid #d69e2e', opacity: 0.5 } : {}; + /** Visual depth inside group tree (= folder depth + 1 for direct children — see `_renderGroup`). */ + const leadingIndentPx = isGrouped ? indentLevel * GROUP_TREE_INDENT_STEP_PX : 0; + const treeIndentCss: React.CSSProperties | undefined = + isGrouped ? { ['--row-tree-indent' as string]: `${leadingIndentPx}px` } : undefined; + return ( onRowClick?.(row, index)} draggable={groupingEnabled || rowDraggable} onDragStart={(e) => { @@ -3111,7 +3121,10 @@ export function FormGeneratorTable>({ {...Object.fromEntries(Object.entries(dataAttributes).map(([k, v]) => [`data-${k}`, v]))} > {selectable && ( - + handleRowSelect(row)} @@ -3123,7 +3136,10 @@ export function FormGeneratorTable>({ )} {hasActionColumn && ( - +
{ if (el) actionButtonsRefs.current.set(index, el); else actionButtonsRefs.current.delete(index); }} className={`${styles.actionButtons} ${shouldWrapActionButtons ? styles.actionButtonsWrap : ''}`}> {actionButtons.map((ab, ai) => { @@ -3155,13 +3171,21 @@ export function FormGeneratorTable>({
)} - {detectedColumns.map(col => { + {detectedColumns.map((col) => { const cv = row[col.key]; const cCls = col.cellClassName ? col.cellClassName(cv, row) : ''; const aStyle = _columnAlignStyle(col); return ( - + {formatCellValue(cv, col, row)} ); @@ -3181,9 +3205,25 @@ export function FormGeneratorTable>({ const _visibleById = new Map(); displayData.forEach(row => _visibleById.set(_getRowId(row), row)); + const _collectSelectableSubtreeIds = (n: typeof groupTree[0]): string[] => { + const acc: string[] = []; + for (const id of n.itemIds) { + const r = _visibleById.get(id); + if (!r) continue; + if (isRowSelectable && !isRowSelectable(r)) continue; + acc.push(id); + } + for (const sg of n.subGroups) acc.push(..._collectSelectableSubtreeIds(sg)); + return acc; + }; + const _renderGroup = (node: typeof groupTree[0], depth: number, inheritedHidden = false): React.ReactNode => { const visibleIds = node.itemIds.filter(id => _visibleById.has(id)); const groupItems = visibleIds.map(id => _visibleById.get(id)!); + const subtreeSelectableIds = _collectSelectableSubtreeIds(node); + const subtreeSelectedCount = subtreeSelectableIds.filter(id => selectedIds.has(id)).length; + const subtreeAllSelected = subtreeSelectableIds.length > 0 && subtreeSelectedCount === subtreeSelectableIds.length; + const subtreePartial = subtreeSelectedCount > 0 && subtreeSelectedCount < subtreeSelectableIds.length; const userCollapsed = expandedGroups.has(`collapsed-${node.id}`); // Visual expanded state (chevron icon, hover-expand override, drag-collapse override) @@ -3241,7 +3281,24 @@ export function FormGeneratorTable>({ { + if (subtreeSelectableIds.length === 0) return; + const next = new Set(selectedIds); + if (subtreeAllSelected) subtreeSelectableIds.forEach(id => next.delete(id)); + else subtreeSelectableIds.forEach(id => next.add(id)); + _notifySelection(next); + }, + } : undefined} visibleCount={visibleIds.length} isExpanded={isExp} isEditing={editingGroupId === node.id} diff --git a/src/components/FormGenerator/GroupingManager/GroupRow.module.css b/src/components/FormGenerator/GroupingManager/GroupRow.module.css index a00d6e7..376595d 100644 --- a/src/components/FormGenerator/GroupingManager/GroupRow.module.css +++ b/src/components/FormGenerator/GroupingManager/GroupRow.module.css @@ -40,6 +40,17 @@ border-left: 3px solid #d69e2e; } +/* Folder subtree selection (aligned with tbody .tr.selected) */ +.groupFolderRow.folderRowSubtreeFull { + background: rgba(124, 109, 216, 0.08); + background: rgba(var(--color-secondary-rgb), 0.08); +} + +.groupFolderRow.folderRowSubtreePartial { + background: rgba(124, 109, 216, 0.04); + background: rgba(var(--color-secondary-rgb), 0.04); +} + .folderCell { padding: 0 !important; width: 100%; diff --git a/src/components/FormGenerator/GroupingManager/GroupRow.tsx b/src/components/FormGenerator/GroupingManager/GroupRow.tsx index fb97bef..10f77ca 100644 --- a/src/components/FormGenerator/GroupingManager/GroupRow.tsx +++ b/src/components/FormGenerator/GroupingManager/GroupRow.tsx @@ -3,6 +3,7 @@ import ReactDOM from 'react-dom'; import { useLanguage } from '../../../providers/language/LanguageContext'; import { useConfirm } from '../../../hooks/useConfirm'; import styles from './GroupRow.module.css'; +import fgTableCss from '../FormGeneratorTable/FormGeneratorTable.module.css'; import type { TableGroupNode } from '../FormGeneratorTable/FormGeneratorTable'; import { FaFolder, FaFolderOpen, FaList, FaPen, FaPlus } from 'react-icons/fa'; @@ -18,14 +19,36 @@ export interface GroupBulkAction { disabled?: boolean; } +/** Horizontal shift per nesting level — keep in sync with item rows (`FormGeneratorTable`). */ +export const GROUP_TREE_INDENT_STEP_PX = 20; + // --------------------------------------------------------------------------- // GroupFolderRow // --------------------------------------------------------------------------- +/** Folder row: optional select column, then one merged cell for folder UI (spans actions + data cols — no blank actions column). */ +export interface GroupFolderTableCells { + showSelect: boolean; + /** `` for folder strip = `detectedColumns.length` + (1 if table has an actions column). */ + dataColumnsCount: number; + selectClassName: string; + selectTdStyle?: React.CSSProperties; +} + interface GroupFolderRowProps { node: TableGroupNode; depth: number; - colSpan: number; + /** Checkbox for “whole subtree”: select / clear all selectable visible items under this folder. */ + subtreeSelect?: { + checked: boolean; + indeterminate: boolean; + disabled: boolean; + onToggle: () => void; + }; + /** When set, use split `` layout; omit single-cell colspan. */ + tableCells?: GroupFolderTableCells; + /** Legacy single spanning cell — only used when `tableCells` is omitted. */ + colSpan?: number; visibleCount: number; isExpanded: boolean; isEditing: boolean; @@ -60,6 +83,8 @@ interface GroupFolderRowProps { export function GroupFolderRow({ node, depth, + subtreeSelect, + tableCells, colSpan, visibleCount, isExpanded, @@ -87,8 +112,15 @@ export function GroupFolderRow({ const { t } = useLanguage(); const { ConfirmDialog } = useConfirm(); const inputRef = useRef(null); + const subtreeCbRef = useRef(null); const totalCount = node.itemIds.length; + useEffect(() => { + const el = subtreeCbRef.current; + if (!el || !subtreeSelect) return; + el.indeterminate = subtreeSelect.indeterminate; + }, [subtreeSelect?.indeterminate, subtreeSelect?.checked, subtreeSelect]); + useEffect(() => { if (isEditing && inputRef.current) { inputRef.current.focus(); @@ -96,26 +128,55 @@ export function GroupFolderRow({ } }, [isEditing]); - const indentPx = depth * 20; + const indentPx = depth * GROUP_TREE_INDENT_STEP_PX; const _rowClass = [ styles.groupFolderRow, + tableCells ? fgTableCss.treeRowIndented : '', isDragOver ? styles.dragOver : '', isDragOverFromGroup ? styles.dragOverGroup : '', isDraggingOut ? styles.draggingOut : '', + subtreeSelect?.checked && !subtreeSelect?.disabled ? styles.folderRowSubtreeFull : '', + subtreeSelect?.indeterminate && !subtreeSelect?.checked ? styles.folderRowSubtreePartial : '', ].filter(Boolean).join(' '); - return ( + const mergedColSpan = + tableCells + ? tableCells.dataColumnsCount + : (colSpan ?? 1); + + const folderStripStyle = + ({ + '--group-indent': `${indentPx}px`, + ...(tableCells + ? { ['--row-tree-indent' as string]: `${depth * GROUP_TREE_INDENT_STEP_PX}px` } + : {}), + }) as React.CSSProperties; + + const guardDragDecor = ( + e: React.DragEvent, + relay: React.DragEventHandler | undefined, + ) => { + const el = e.target as HTMLElement; + if (el.closest('input, button, textarea, label')) { + e.preventDefault(); + e.stopPropagation(); + return; + } + relay?.(e); + }; + + const folderCells = ( <> {typeof document !== 'undefined' && ReactDOM.createPortal(, document.body)} guardDragDecor(e, onGroupDragStart)} + onDrag={(e) => guardDragDecor(e, onGroupDrag)} + onDragEnd={(e) => guardDragDecor(e, onGroupDragEnd)} // item drag-over onDragOver={(e) => { // distinguish item vs group drag via dataTransfer type @@ -135,7 +196,23 @@ export function GroupFolderRow({ onDragLeave={() => { onItemDragLeave(); onGroupDragLeave(); }} onDragEnter={(e) => e.preventDefault()} > - + {tableCells?.showSelect && ( + + {subtreeSelect && ( + { e.stopPropagation(); subtreeSelect.onToggle(); }} + onClick={(e) => e.stopPropagation()} + title={node.name ? t('Auswahl unter „{name}“', { name: node.name }) : t('Auswahl dieser Gruppe')} + aria-label={node.name ? t('Alle sichtbaren Einträge in „{name}“ auswählen', { name: node.name }) : t('Alle sichtbaren Einträge in dieser Gruppe auswählen')} + /> + )} + + )} +
{/* Indent */} {indentPx > 0 && } @@ -143,6 +220,7 @@ export function GroupFolderRow({ {/* Chevron */} - + + )} @@ -224,6 +303,8 @@ export function GroupFolderRow({ ); + + return folderCells; } // --------------------------------------------------------------------------- diff --git a/src/hooks/useFiles.ts b/src/hooks/useFiles.ts index b2b59d4..3ffe53b 100644 --- a/src/hooks/useFiles.ts +++ b/src/hooks/useFiles.ts @@ -14,6 +14,7 @@ import { deleteFiles as deleteFilesApi, type FolderInfo, } from '../api/fileApi'; +import type { TableGroupNode } from '../api/connectionApi'; export interface FilePreviewResult { success: boolean; @@ -73,6 +74,7 @@ export function useUserFiles() { totalItems: number; totalPages: number; } | null>(null); + const [groupTree, setGroupTree] = useState([]); const { request, isLoading: loading, error } = useApiRequest(); const { checkPermission } = usePermissions(); @@ -172,6 +174,9 @@ export function useUserFiles() { if (data.pagination) { setPagination(data.pagination); } + if (Array.isArray((data as any).groupTree)) { + setGroupTree((data as any).groupTree); + } } else { // Handle non-paginated response (backward compatibility) console.log('📋 Processing non-paginated response:', { @@ -325,6 +330,7 @@ export function useUserFiles() { attributes, permissions, pagination, + groupTree, fetchFileById, generateEditFieldsFromAttributes, ensureAttributesLoaded diff --git a/src/hooks/usePrompts.ts b/src/hooks/usePrompts.ts index 25c2d55..20870e7 100644 --- a/src/hooks/usePrompts.ts +++ b/src/hooks/usePrompts.ts @@ -13,6 +13,7 @@ import { type AttributeDefinition, type PaginationParams } from '../api/promptApi'; +import type { TableGroupNode } from '../api/connectionApi'; // Re-export types for backward compatibility export type { Prompt, AttributeDefinition, PaginationParams }; @@ -34,6 +35,7 @@ export function usePrompts() { totalItems: number; totalPages: number; } | null>(null); + const [groupTree, setGroupTree] = useState([]); const { request, isLoading: loading, error } = useApiRequest(); const { checkPermission } = usePermissions(); @@ -99,6 +101,9 @@ export function usePrompts() { if (data.pagination) { setPagination(data.pagination); } + if (Array.isArray((data as any).groupTree)) { + setGroupTree((data as any).groupTree); + } } else { // Handle non-paginated response (backward compatibility) const items = Array.isArray(data) ? data : []; @@ -454,10 +459,11 @@ export function usePrompts() { attributes, permissions, pagination, + groupTree, fetchPromptById, generateEditFieldsFromAttributes, generateCreateFieldsFromAttributes, - ensureAttributesLoaded // Generic function to ensure attributes are loaded + ensureAttributesLoaded }; } diff --git a/src/pages/basedata/FilesPage.tsx b/src/pages/basedata/FilesPage.tsx index e7c98d3..2cce1fc 100644 --- a/src/pages/basedata/FilesPage.tsx +++ b/src/pages/basedata/FilesPage.tsx @@ -57,6 +57,7 @@ export const FilesPage: React.FC = () => { error, refetch: tableRefetch, pagination, + groupTree, fetchFileById, updateFileOptimistically, } = useUserFiles(); @@ -556,7 +557,9 @@ export const FilesPage: React.FC = () => { handleInlineUpdate, updateOptimistically: updateFileOptimistically, previewingFiles, + groupTree, }} + groupingConfig={{ contextKey: 'files', enabled: true }} emptyMessage={emptyTableMessage} />
diff --git a/src/pages/basedata/PromptsPage.tsx b/src/pages/basedata/PromptsPage.tsx index 3fa1bdf..86eba34 100644 --- a/src/pages/basedata/PromptsPage.tsx +++ b/src/pages/basedata/PromptsPage.tsx @@ -34,6 +34,7 @@ export const PromptsPage: React.FC = () => { loading, error, refetch, + groupTree, fetchPromptById, updateOptimistically, } = usePrompts(); @@ -236,7 +237,9 @@ export const PromptsPage: React.FC = () => { handleDelete: handlePromptDelete, handleInlineUpdate, updateOptimistically, + groupTree, }} + groupingConfig={{ contextKey: 'prompts', enabled: true }} emptyMessage={t('Keine Prompts gefunden')} /> From 7c05cb0dd757b53492fe201625f0c410ac6790d0 Mon Sep 17 00:00:00 2001 From: Ida Date: Thu, 30 Apr 2026 12:40:43 +0200 Subject: [PATCH 6/6] replaced file tree mit formgenerator gruppierung --- eslint.config.js | 11 + src/api/fileApi.ts | 153 +++--- .../FlowEditor/editor/EditorChatPanel.tsx | 25 +- .../FlowEditor/editor/FlowCanvas.tsx | 3 +- .../ActionButtons/ActionButton.module.css | 28 + .../FormGeneratorTable.module.css | 65 +++ .../FormGeneratorTable/FormGeneratorTable.tsx | 99 +++- src/components/UnifiedDataBar/FilesTab.tsx | 306 ++++------- .../UnifiedDataBar/UnifiedDataBar.tsx | 6 +- src/contexts/FileContext.tsx | 293 +---------- src/hooks/useApi.ts | 4 +- src/hooks/useFiles.ts | 88 ---- src/pages/basedata/FilesPage.tsx | 486 ++++++------------ .../neutralization/NeutralizationView.tsx | 5 +- .../views/teamsbot/TeamsbotSessionView.tsx | 23 +- src/pages/views/workspace/ToolActivityLog.tsx | 10 + src/pages/views/workspace/WorkspaceInput.tsx | 378 +++++++++----- src/pages/views/workspace/WorkspacePage.tsx | 166 +++--- src/pages/views/workspace/useWorkspace.ts | 22 +- tsconfig.json | 3 +- 20 files changed, 904 insertions(+), 1270 deletions(-) diff --git a/eslint.config.js b/eslint.config.js index 092408a..10a64ff 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -23,6 +23,17 @@ export default tseslint.config( 'warn', { allowConstantExport: true }, ], + 'no-restricted-imports': [ + 'warn', + { + patterns: [ + { + group: ['**/components/FolderTree/FolderTree*', '**/FolderTree/FolderTree*'], + message: 'FolderTree is deprecated — use FormGeneratorTable with groupingConfig instead.', + }, + ], + }, + ], }, }, ) diff --git a/src/api/fileApi.ts b/src/api/fileApi.ts index 44102f1..e251006 100644 --- a/src/api/fileApi.ts +++ b/src/api/fileApi.ts @@ -190,110 +190,87 @@ export async function deleteFiles( return uniqueIds.map(fileId => ({ success: true, fileId })); } -export async function deleteFolders( - request: ApiRequestFunction, - folderIds: string[], - recursiveFolders: boolean = true -): Promise<{ deletedFiles: number; deletedFolders: number }> { - const uniqueIds = [...new Set(folderIds.filter(Boolean))]; - if (uniqueIds.length === 0) return { deletedFiles: 0, deletedFolders: 0 }; - return await request({ - url: '/api/files/batch-delete', - method: 'post', - data: { folderIds: uniqueIds, recursiveFolders } - }); -} - // ============================================================================ -// FOLDER API FUNCTIONS +// GROUP BULK API FUNCTIONS // ============================================================================ -export interface FolderInfo { - id: string; - name: string; - parentId: string | null; - fileCount?: number; - mandateId?: string; - featureInstanceId?: string; - createdAt?: number; - scope?: string; - neutralize?: boolean; -} - -export async function fetchFolders( +/** Patch scope for all files in a group (recursive) */ +export async function patchGroupScope( request: ApiRequestFunction, - parentId?: string | null -): Promise { - const params: any = {}; - if (parentId !== undefined && parentId !== null) { - params.parentId = parentId; - } - const data = await request({ - url: '/api/files/folders', - method: 'get', - params, - }); - return Array.isArray(data) ? data : []; -} - -export async function createFolder( - request: ApiRequestFunction, - name: string, - parentId?: string | null -): Promise { - return await request({ - url: '/api/files/folders', - method: 'post', - data: { name, parentId: parentId || null }, - }); -} - -export async function renameFolder( - request: ApiRequestFunction, - folderId: string, - name: string + groupId: string, + scope: string ): Promise { return await request({ - url: `/api/files/folders/${folderId}`, - method: 'put', - data: { name }, + url: `/api/files/groups/${groupId}/scope`, + method: 'patch', + data: { scope }, }); } -export async function deleteFolderApi( +/** Patch neutralize for all files in a group (recursive, incl. knowledge purge/reindex) */ +export async function patchGroupNeutralize( request: ApiRequestFunction, - folderId: string, - recursive: boolean = false + groupId: string, + neutralize: boolean ): Promise { return await request({ - url: `/api/files/folders/${folderId}`, + url: `/api/files/groups/${groupId}/neutralize`, + method: 'patch', + data: { neutralize }, + }); +} + +/** Download all files in a group as ZIP */ +export async function downloadGroupZip(groupId: string): Promise { + const { default: api } = await import('../api'); + const response = await api.get(`/api/files/groups/${groupId}/download`, { + responseType: 'blob', + }); + const url = window.URL.createObjectURL(response.data); + const link = document.createElement('a'); + link.href = url; + link.setAttribute('download', `group-${groupId}.zip`); + document.body.appendChild(link); + link.click(); + link.remove(); + window.URL.revokeObjectURL(url); +} + +/** Delete a group and optionally all its files */ +export async function deleteGroup( + request: ApiRequestFunction, + groupId: string, + deleteItems: boolean = false +): Promise { + return await request({ + url: `/api/files/groups/${groupId}`, method: 'delete', - params: { recursive }, + params: { deleteItems }, }); } -export async function moveFolder( - request: ApiRequestFunction, - folderId: string, - targetParentId: string | null -): Promise { - return await request({ - url: `/api/files/folders/${folderId}/move`, - method: 'post', - data: { targetParentId }, - }); -} - -export async function moveFile( - request: ApiRequestFunction, - fileId: string, - targetFolderId: string | null -): Promise { - return await request({ - url: `/api/files/${fileId}/move`, - method: 'post', - data: { targetFolderId }, - }); +/** Collect all file IDs belonging to a group recursively (client-side, from known groupTree) */ +export function collectGroupItemIds( + groupTree: Array<{ id: string; itemIds: string[]; subGroups: any[] }>, + groupId: string +): string[] { + const collect = (nodes: Array<{ id: string; itemIds: string[]; subGroups: any[] }>): string[] | null => { + for (const node of nodes) { + if (node.id === groupId) { + const ids: string[] = [...node.itemIds]; + const sub = (n: { id: string; itemIds: string[]; subGroups: any[] }) => { + ids.push(...n.itemIds); + n.subGroups.forEach(sub); + }; + node.subGroups.forEach(sub); + return ids; + } + const found = collect(node.subGroups); + if (found) return found; + } + return null; + }; + return collect(groupTree) ?? []; } // Note: The following operations require special handling (FormData, blob responses) diff --git a/src/components/FlowEditor/editor/EditorChatPanel.tsx b/src/components/FlowEditor/editor/EditorChatPanel.tsx index 84064ff..3ce248e 100644 --- a/src/components/FlowEditor/editor/EditorChatPanel.tsx +++ b/src/components/FlowEditor/editor/EditorChatPanel.tsx @@ -4,7 +4,7 @@ * AI Chat sidebar for the GraphicalEditor. * Streams responses via SSE (same pattern as Workspace chat). * File & data-source attachment UX mirrors WorkspaceInput: - * - Files: drag & drop from FolderTree onto input area, or click in UDB + * - Files: drag & drop from FilesTab (UDB) onto input area, or click in UDB * - Data Sources: 🔗 picker button next to input (toggle-select from active sources) */ import React, { useState, useCallback, useEffect, useRef } from 'react'; @@ -32,7 +32,7 @@ import { useLanguage } from '../../../providers/language/LanguageContext'; export interface PendingFile { fileId: string; fileName: string; - itemType?: 'file' | 'folder'; + itemType?: 'file' | 'group'; } export interface EditorDataSource { @@ -241,7 +241,12 @@ export const EditorChatPanel: React.FC = ({ instanceId, }, [_handleSend]); const _handleDragOver = useCallback((e: React.DragEvent) => { - if (e.dataTransfer.types.includes('application/tree-items')) { + if ( + e.dataTransfer.types.includes('application/tree-items') || + e.dataTransfer.types.includes('application/group-id') || + e.dataTransfer.types.includes('application/file-id') || + e.dataTransfer.types.includes('application/file-ids') + ) { e.preventDefault(); e.dataTransfer.dropEffect = 'copy'; setTreeDropOver(true); @@ -252,6 +257,12 @@ export const EditorChatPanel: React.FC = ({ instanceId, const _handleDrop = useCallback((e: React.DragEvent) => { setTreeDropOver(false); + const groupId = e.dataTransfer.getData('application/group-id'); + if (groupId) { + e.preventDefault(); + e.stopPropagation(); + return; + } const treeItemsJson = e.dataTransfer.getData('application/tree-items'); if (treeItemsJson) { e.preventDefault(); @@ -282,11 +293,11 @@ export const EditorChatPanel: React.FC = ({ instanceId, - {pf.itemType === 'folder' ? '\uD83D\uDCC1' : '\uD83D\uDCCE'} {pf.fileName.length > 20 ? pf.fileName.slice(0, 20) + '...' : pf.fileName} + {pf.itemType === 'group' ? '\uD83D\uDCC2' : '\uD83D\uDCCE'} {pf.fileName.length > 20 ? pf.fileName.slice(0, 20) + '...' : pf.fileName} {onRemovePendingFile && (